Compare commits

..

24 Commits

Author SHA1 Message Date
jubnl a63e16fb65 fix(airtrail): don't use cabin class as seat on import
When an AirTrail flight has a cabin class but no seat number, the mapper
fell back to the class for metadata.seat, so reservations showed e.g.
"economy" as the seat. Use only the seat number; leave the seat blank
otherwise. The class is still surfaced separately in the import picker.

Closes #1246
2026-06-18 14:12:41 +02:00
jubnl dfe98a057c chore(prettier) prettier this file 2026-06-18 14:05:19 +02:00
jubnl d5850041a7 fix(costs): rework the cost panel UX wise and apply prettier on the shared package 2026-06-18 13:59:10 +02:00
jubnl ad6e1ddcc8 fix(airtrail): add back missing tests 2026-06-18 10:04:33 +02:00
jubnl 66f661e2a1 fix(airtrail): gate airtrail update behind a user setting, on airtrail update: rebuild payload from fresh data to prevent any data loss 2026-06-18 09:59:14 +02:00
Maurice 17b4f72be6 fix(dashboard): never crash on a malformed reservation date
A reservation with an invalid date blanked the whole My Trips page: the old
Upcoming widget did new Date(value).toISOString(), which throws "Invalid time
value" (fixed in #1222 by reading the string parts). Also guard splitDate so a
bad date renders a dash instead of "Invalid Date" or throwing.
2026-06-17 23:26:59 +02:00
Maurice 7aefeb4c53 fix(atlas): give every sub-national region a distinct code (#1217)
geoBoundaries fills shapeISO with the bare country code for some countries (every
Spanish region got "ESP", every Chinese "CHN", also Chile/Oman), so marking one
region lit up the whole country. build-atlas-geo.mjs now keeps shapeISO only when
it is a real "XX-..." subdivision code and otherwise synthesizes a unique
per-country id from the region name. Regenerated admin1.geojson.gz: Spain/China/
Chile/Oman now carry distinct region codes (countries with real codes, e.g.
Germany, are unchanged).
2026-06-17 23:19:51 +02:00
Maurice 63fb5a9c89 feat(admin): let admins set a default currency for new users
Adds a currency picker to Admin > User Defaults. Stored as the default_currency
user-default, so users who have not picked their own currency inherit it in
Costs.
2026-06-17 23:12:30 +02:00
Maurice 17245c5a8c fix(atlas): keep the continent breakdown in sync on mark/unmark (#1225)
The optimistic mark/unmark updates bumped the country total but never the
per-continent counts, so the continent column froze until a full reload. Move
the country to continent map into @trek/shared (single source for server and
client) and adjust the matching continent count at every optimistic site: the
country confirm flow plus the choose / region mark and region unmark handlers.
2026-06-17 23:12:30 +02:00
Maurice 6ab4989c38 fix(planner): let a booking's day follow its date when edited (#1237)
Preserving the old day_id on edit left a re-dated booking on its previous start
day while end_day_id followed the new date, so it spanned both. Stop sending
day_id from the edit modal entirely - the server derives both ends from the
booking's date (and keeps the current day when there is no date), so a re-dated
booking moves cleanly to the matching day.
2026-06-17 22:38:58 +02:00
Maurice ea7f7fd9f3 fix(planner): derive a booking day from its date when none is set (#1237)
The client always sends day_id on a reservation update, so the server only
derived it from reservation_time when the field was absent. A non-transport
booking saved without a selected day (Book tab) therefore got day_id null and
vanished from the Plan, even though its date matched a day. Derive the day from
reservation_time whenever day_id is null, mirroring create.
2026-06-17 22:32:03 +02:00
Maurice 00738c8dbc fix(planner): keep a reservation on its day when edited (#1237)
Editing a booking forced its day_id to the globally selected day, which is null
when editing from the Book tab - so the booking lost its day and vanished from
the Plan. Preserve the reservation own day_id on edit instead.
2026-06-17 22:27:54 +02:00
Maurice 438f71bbc6 test(reservations): align syncBudgetOnUpdate unit tests with no-wipe + type-sync
The service now leaves a linked expense alone when no budget entry is on the
payload (only an explicit total_price 0 deletes it) and syncs the category on a
booking type change. Update the unit tests accordingly - the old "price cleared"
case passed entry: undefined, which is now a no-op and left a mocked return
queued that leaked into the next test.
2026-06-17 22:27:53 +02:00
Maurice c15c89ca61 feat(costs): create an expense from a booking, fix editing total-only items
Replace the inline price + budget-category fields in the Transport and
Reservation booking modals with a "Create expense" flow: the modal saves the
booking, then opens the full Costs editor prefilled (name + category mapped from
the booking type) and linked to the reservation. A booking with a linked expense
shows it inline with edit / remove.

Also fix the Costs editor so an expense with a recorded total but no payers
(transport-derived or pre-rework items) opens with its amount, lets you set the
currency, and saves - it previously showed 0 everywhere and could not be saved.
Legacy / localized categories now map to the fixed keys, and changing a booking's
type keeps its linked expense category in sync (unless it was manually set).

- shared: reservation_id on budget create, typeToCostCategory helper, i18n keys
- server: createBudgetItem stores reservation_id; keep total_price for payerless
  items; a booking update no longer wipes its linked expense and syncs the
  category on type change
- client: shared BookingCostsSection, exported ExpenseModal with prefill and an
  editable total, page-level save-then-open wiring
2026-06-17 22:11:56 +02:00
Maurice f98058a3af feat(backup): make the upload size limit configurable
The restore upload was capped at a hard-coded 500 MB, so instances whose
backup archive (uploads/ included) grew past that got a 413 "File too large"
with no way to raise it. Add a BACKUP_UPLOAD_LIMIT_MB env var (default 500,
invalid values warn and fall back), documented in .env.example.
2026-06-17 21:00:36 +02:00
Maurice 39a3ee7ce7 fix(collab): show poll option labels in the UI
The poll API formatted each option as { label, voters }, but the React poll
component renders opt.text - so every option button came out blank. Emit text
alongside label (kept for any other consumer) so options render again.
2026-06-17 21:00:19 +02:00
Maurice e09849d5b4 fix(oidc): keep dots in generated usernames
The OIDC username sanitizer stripped dots because they were missing from the
allowed character class, so a name claim like "first.last" became "firstlast".
Dots are valid usernames (the profile validator already allows
^[a-zA-Z0-9_.-]+$), so add the dot to the sanitizer.
2026-06-17 21:00:04 +02:00
Maurice b3fc5411ca fix(atlas): cursor-following tooltips and removing countries from search
Two related Atlas fixes:

- Country tooltips were bound with sticky:false, which anchors them at the
  feature's bounds centre. For countries with overseas territories (e.g.
  France) that centre sits far out in the ocean, so the tooltip popped up
  nowhere near the area being hovered. Make them sticky so they track the
  cursor.

- Selecting an already-visited country from the search bar always opened the
  "Mark / Bucket" dialog, with no way to remove it. Tiny countries like
  Vatican City or Singapore are hard to hit on the map, so search was the only
  way in. Mirror the map-click behaviour: a manually-marked country opens the
  Remove confirmation, a trip/place-backed one opens its detail.
2026-06-17 15:23:54 +02:00
Maurice f524909008 fix(dashboard): show the correct reservation date regardless of timezone
The upcoming-reservations widget built the date with new Date(reservation_time)
.toISOString(), which reinterprets the stored naive local time as UTC and can
roll the displayed day forward in non-UTC timezones (e.g. a 23:30 reservation
showing the next day). Read the date and time straight from the stored string
parts via splitReservationDateTime, and format the time with the shared
formatTime helper so it also honours the user's 12h/24h preference.
2026-06-17 15:23:35 +02:00
Maurice 264cf7d384 fix(vacay): keep the mode toolbar above the mobile bottom nav
The floating Vacation/Company toolbar was pinned at bottom-3 with z-30, so on
mobile it landed in the same band as the fixed bottom nav (z-60) and got hidden
behind it - and could scroll out of reach entirely. Pin it above the nav with
the shared --bottom-nav-h variable (0px on desktop, so nothing changes there)
and reserve matching space below the calendar grid so it never gets swallowed.
2026-06-17 15:23:23 +02:00
Maurice cb7ce7f229 fix(docker): ship the encryption-key migration script in the image
The production image only copied server/dist, so the documented rotation
command `node --import tsx scripts/migrate-encryption.ts` failed inside the
container with a module-not-found error - the raw .ts was never present. The
script runs via tsx straight from source and only pulls node builtins plus
better-sqlite3 (both prod deps), so copying the single file into
/app/server/scripts is enough to make the rotation work again.
2026-06-17 15:04:29 +02:00
Maurice d40c5ce7a6 fix(demo): skip first-run admin seed in demo mode
When DEMO_MODE is on, the demo seeder creates its own admin (admin@trek.app,
username "admin") right after the generic seeds run. The first-run admin
bootstrap was grabbing username "admin" first, so the demo seeder hit the
UNIQUE(username) constraint and aborted before the demo user was ever created
- which surfaced as a 500 "Demo user not found" on demo-login. Skip the
generic admin bootstrap when demo mode owns the admin account.
2026-06-17 15:01:41 +02:00
jubnl 2d79254c33 feat(pdf): add legs to pdf export 2026-06-17 11:05:35 +02:00
jubnl e6fcbc7789 fix(shared-view): render each leg of multi-leg flights correctly
The read-only shared view showed the overall trip start/end airports and
the first leg's flight number on every leg of a multi-leg flight. The Day
Plan already expands legs (each carries __leg), but the renderer ignored it
and read flat top-level metadata; the Bookings tab had the same bug.

- Day Plan: use __leg for per-leg airline/flight number/route, plus dep-arr time
- Bookings tab: list each leg via getFlightLegs()
- unique React keys for multi-leg rows

Closes #1219
2026-06-17 10:44:05 +02:00
42 changed files with 1660 additions and 2703 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 3.1.1
version: 3.1.0
description: Minimal Helm chart for TREK app
appVersion: "3.1.1"
appVersion: "3.1.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/client",
"version": "3.1.1",
"version": "3.1.0",
"private": true,
"type": "module",
"scripts": {
@@ -399,38 +399,17 @@ describe('PlaceFormModal', () => {
expect(screen.queryByTestId('time-picker')).not.toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-026: time section is hidden in edit mode when no assignment is in context', () => {
// Times are per day-assignment; editing a pool place with no day in context
// (assignmentId null) hides the fields, which otherwise would not persist (#1247)
it('FE-PLANNER-PLACEFORM-026: time section IS shown in edit mode', () => {
const place = buildPlace({ name: 'Test' });
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={null} />);
expect(screen.queryByTestId('time-picker')).not.toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-026b: time section IS shown when an assignment is in context', () => {
const place = buildPlace({ name: 'Test', place_time: '09:00', end_time: '10:00' });
const assignment = buildAssignment({ id: 10, day_id: 5, place });
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={10} dayAssignments={[assignment]} />);
// Time pickers are rendered when editing
expect(screen.getAllByTestId('time-picker').length).toBeGreaterThanOrEqual(2);
});
it('FE-PLANNER-PLACEFORM-026c: hydrates Start/End from the assignment when the pool place lacks times (#1247)', () => {
// The pool Place carries no times — they live on the day-assignment. Opening the
// editor with an assignmentId must hydrate the fields from assignment.place, not
// the (timeless) pool place that the Places panel passes in.
const poolPlace = buildPlace({ id: 7, name: 'Museum' });
const assignmentPlace = buildPlace({ id: 7, name: 'Museum', place_time: '20:20', end_time: '20:34' });
const assignment = buildAssignment({ id: 42, day_id: 3, place: assignmentPlace });
render(<PlaceFormModal {...defaultProps} place={poolPlace} assignmentId={42} dayAssignments={[assignment]} />);
expect(screen.getByDisplayValue('20:20')).toBeInTheDocument();
expect(screen.getByDisplayValue('20:34')).toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-027: end-before-start error disables submit', () => {
// Build an assignment whose place has end_time before place_time
// Build a place with end_time before place_time
const place = buildPlace({ name: 'Test', place_time: '14:00', end_time: '13:00' });
const assignment = buildAssignment({ id: 11, day_id: 5, place });
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={11} dayAssignments={[assignment]} />);
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={null} />);
// hasTimeError = true → submit button disabled
const submitBtn = screen.getByRole('button', { name: /^Update$/i });
@@ -92,11 +92,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
useEffect(() => {
if (place) {
// Times are stored per day-assignment, not on the pool place. When an
// assignment is in context (itinerary edit, or a single-assignment pool
// edit) read the times off its embedded place; fall back to the place prop.
const assignment = assignmentId ? dayAssignments.find(a => a.id === assignmentId) : null
const timeSource = assignment?.place ?? place
setForm({
name: place.name || '',
description: place.description || '',
@@ -104,8 +99,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
lat: place.lat != null ? String(place.lat) : '',
lng: place.lng != null ? String(place.lng) : '',
category_id: place.category_id != null ? String(place.category_id) : '',
place_time: timeSource.place_time || '',
end_time: timeSource.end_time || '',
place_time: place.place_time || '',
end_time: place.end_time || '',
notes: place.notes || '',
transport_mode: place.transport_mode || 'walking',
website: place.website || '',
@@ -126,10 +121,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
}
setPendingFiles([])
setDuplicateWarning(null)
// dayAssignments is a fresh array each render; read it at open-time only and
// re-run on identity changes (place/assignmentId/open), not on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [place, prefillCoords, isOpen, assignmentId])
}, [place, prefillCoords, isOpen])
// Derive location bias bounding box from the trip's existing places
const places = useTripStore((s) => s.places)
@@ -736,11 +728,8 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
)}
</div>
{/* Time is per day-assignment: only shown when a single assignment is in
context (itinerary edit, or a single-assignment pool edit). Hidden when
creating, and for unassigned / multi-day pool edits where a single time
is ambiguous and wouldn't persist. */}
{place && assignmentId && (
{/* Time — only shown when editing, not when creating */}
{place && (
<TimeSection
form={form}
handleChange={handleChange}
+27 -6
View File
@@ -203,7 +203,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
expandedDayIds, setExpandedDayIds, mapPlaces,
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
handleSavePlace, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
handleAssignToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
selectedPlace, dayOrderMap, dayPlaces,
@@ -465,7 +465,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
onPlaceClick={handlePlaceClick}
onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true) }}
onAssignToDay={handleAssignToDay}
onEditPlace={(place) => openPlaceEditor(place)}
onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true) }}
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)}
onCategoryFilterChange={setMapCategoryFilter}
@@ -531,7 +531,17 @@ export default function TripPlannerPage(): React.ReactElement | null {
assignments={assignments}
reservations={reservations}
onClose={() => setSelectedPlaceId(null)}
onEdit={() => openPlaceEditor(selectedPlace, selectedAssignmentId)}
onEdit={() => {
if (selectedAssignmentId) {
const assignmentObj = Object.values(assignments).flat().find(a => a.id === selectedAssignmentId)
const placeWithAssignmentTimes = assignmentObj?.place ? { ...selectedPlace, place_time: assignmentObj.place.place_time, end_time: assignmentObj.place.end_time } : selectedPlace
setEditingPlace(placeWithAssignmentTimes)
} else {
setEditingPlace(selectedPlace)
}
setEditingAssignmentId(selectedAssignmentId || null)
setShowPlaceForm(true)
}}
onDelete={() => handleDeletePlace(selectedPlace.id)}
onAssignToDay={handleAssignToDay}
onRemoveAssignment={handleRemoveAssignment}
@@ -569,7 +579,18 @@ export default function TripPlannerPage(): React.ReactElement | null {
assignments={assignments}
reservations={reservations}
onClose={() => setSelectedPlaceId(null)}
onEdit={() => { openPlaceEditor(selectedPlace, selectedAssignmentId); setSelectedPlaceId(null) }}
onEdit={() => {
if (selectedAssignmentId) {
const assignmentObj = Object.values(assignments).flat().find(a => a.id === selectedAssignmentId)
const placeWithAssignmentTimes = assignmentObj?.place ? { ...selectedPlace, place_time: assignmentObj.place.place_time, end_time: assignmentObj.place.end_time } : selectedPlace
setEditingPlace(placeWithAssignmentTimes)
} else {
setEditingPlace(selectedPlace)
}
setEditingAssignmentId(selectedAssignmentId || null)
setShowPlaceForm(true)
setSelectedPlaceId(null)
}}
onDelete={() => { handleDeletePlace(selectedPlace.id); setSelectedPlaceId(null) }}
onAssignToDay={handleAssignToDay}
onRemoveAssignment={handleRemoveAssignment}
@@ -610,7 +631,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
<div style={{ flex: 1, overflow: 'auto' }}>
{mobileSidebarOpen === 'left'
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded />
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
}
</div>
</div>
@@ -696,7 +717,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
)}
</div>
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingPlace ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} />
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingAssignmentId ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} />
<TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripActions.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} onOpenExpense={openBookingExpense} />
@@ -1,25 +0,0 @@
import { describe, it, expect } from 'vitest'
import { resolvePoolAssignmentId } from './tripPlannerModel'
import { buildAssignment, buildPlace } from '../../../tests/helpers/factories'
describe('resolvePoolAssignmentId', () => {
it('returns the lone assignment id when the place is assigned to exactly one day', () => {
const place = buildPlace({ id: 7 })
const assignment = buildAssignment({ id: 42, day_id: 3, place })
const assignments = { 3: [assignment], 4: [buildAssignment({ id: 99, day_id: 4 })] }
expect(resolvePoolAssignmentId(assignments, 7)).toBe(42)
})
it('returns null when the place is not assigned to any day', () => {
const assignments = { 3: [buildAssignment({ id: 99, day_id: 3 })] }
expect(resolvePoolAssignmentId(assignments, 7)).toBeNull()
})
it('returns null when the place is assigned to multiple days (ambiguous time)', () => {
const assignments = {
3: [buildAssignment({ id: 1, day_id: 3, place: buildPlace({ id: 7 }) })],
4: [buildAssignment({ id: 2, day_id: 4, place: buildPlace({ id: 7 }) })],
}
expect(resolvePoolAssignmentId(assignments, 7)).toBeNull()
})
})
@@ -1,24 +0,0 @@
/**
* Trip planner pure helpers — React/IO-free logic shared by the data hook
* (useTripPlanner) and kept here so it can be unit-tested in isolation. Part of
* the FE "page = wiring container + data hook" convention (see PATTERN.md).
*/
import type { Assignment } from '../../types'
/**
* Resolve the day-assignment to use when a place is edited from the Places pool,
* where no day is in context. Times live per day-assignment (#1247), so we can
* only hydrate/persist a place's time when it is assigned to exactly one day.
* Returns that assignment's id, or null when the place has 0 or 2+ assignments
* (ambiguous — the modal then hides the time fields).
*/
export function resolvePoolAssignmentId(
assignments: Record<string | number, Assignment[]>,
placeId: number,
): number | null {
const matches = Object.values(assignments)
.flat()
.filter((a) => a.place?.id === placeId)
return matches.length === 1 ? matches[0].id : null
}
+1 -12
View File
@@ -18,7 +18,6 @@ import { usePlaceSelection } from '../../hooks/usePlaceSelection'
import { usePlannerHistory } from '../../hooks/usePlannerHistory'
import { useAirtrailConnection } from '../../hooks/useAirtrailConnection'
import type { Accommodation, TripMember, Day, Place, Reservation } from '../../types'
import { resolvePoolAssignmentId } from './tripPlannerModel'
/**
* Trip planner page logic — the big one. Owns the trip store wiring, addon
@@ -424,16 +423,6 @@ export function useTripPlanner() {
}
}, [editingPlace, editingAssignmentId, tripId, toast, pushUndo])
// Open the place editor from any entry point (Places pool, inspector, map).
// Times live per day-assignment, so when no day is in context resolve the
// place's lone assignment to hydrate & persist its times; with 0 or 2+
// assignments the time is ambiguous and the modal hides the fields (#1247).
const openPlaceEditor = useCallback((place: Place, preferredAssignmentId: number | null = null) => {
setEditingPlace(place)
setEditingAssignmentId(preferredAssignmentId ?? resolvePoolAssignmentId(assignments, place.id))
setShowPlaceForm(true)
}, [assignments])
const handleDeletePlace = useCallback((placeId) => {
setDeletePlaceId(placeId)
}, [])
@@ -701,7 +690,7 @@ export function useTripPlanner() {
expandedDayIds, setExpandedDayIds, mapPlaces,
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
handleSavePlace, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
handleAssignToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
selectedPlace, dayOrderMap, dayPlaces,
+1415 -1145
View File
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -1,7 +1,7 @@
{
"name": "@trek/root",
"private": true,
"version": "3.1.1",
"version": "3.1.0",
"workspaces": [
"client",
"server",
@@ -30,8 +30,7 @@
"comment:overrides": "Force a single React 19 across the workspace so the test renderer (@testing-library/react) and the app share one react-dom.",
"overrides": {
"react": "19.2.6",
"react-dom": "19.2.6",
"multer": "^2.2.0"
"react-dom": "19.2.6"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-musl": "4.62.0",
+5 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/server",
"version": "3.1.1",
"version": "3.1.0",
"main": "src/index.ts",
"scripts": {
"start": "node --require tsconfig-paths/register dist/index.js",
@@ -38,8 +38,9 @@
"helmet": "^8.1.0",
"jimp": "^1.6.1",
"jsonwebtoken": "^9.0.2",
"multer": "^2.1.1",
"node-cron": "^4.2.1",
"nodemailer": "^9.0.1",
"nodemailer": "^8.0.5",
"otplib": "^12.0.1",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
@@ -59,7 +60,7 @@
"@hono/node-server": "^1.19.13",
"picomatch": "^4.0.4",
"ip-address": "^10.1.1",
"multer": "^2.2.0",
"multer": "^2.1.1",
"ws": "^8.21.0",
"qs": "^6.15.2",
"file-type": "^21.3.4"
@@ -79,7 +80,7 @@
"@types/multer": "^2.1.0",
"@types/node": "^25.5.0",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^8.0.1",
"@types/nodemailer": "^7.0.11",
"@types/qrcode": "^1.5.5",
"@types/semver": "^7.7.1",
"@types/supertest": "^6.0.3",
+36 -56
View File
@@ -1,11 +1,39 @@
import { SUPPORTED_LANGUAGE_CODES as SUPPORTED_LANG_CODES } from '@trek/shared';
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { SUPPORTED_LANGUAGE_CODES as SUPPORTED_LANG_CODES } from '@trek/shared';
const dataDir = path.resolve(__dirname, '../data');
// JWT_SECRET is always managed by the server — auto-generated on first start and
// persisted to data/.jwt_secret. Use the admin panel to rotate it; do not set it
// via environment variable (env var would override a rotation on next restart).
const jwtSecretFile = path.join(dataDir, '.jwt_secret');
let _jwtSecret: string;
try {
_jwtSecret = fs.readFileSync(jwtSecretFile, 'utf8').trim();
} catch {
_jwtSecret = crypto.randomBytes(32).toString('hex');
try {
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(jwtSecretFile, _jwtSecret, { mode: 0o600 });
console.log('Generated and saved JWT secret to', jwtSecretFile);
} catch (writeErr: unknown) {
console.warn('WARNING: Could not persist JWT secret to disk:', writeErr instanceof Error ? writeErr.message : writeErr);
console.warn('Sessions will reset on server restart.');
}
}
// export let so TypeScript's CJS output keeps exports.JWT_SECRET live
// (generates `exports.JWT_SECRET = JWT_SECRET = newVal` inside updateJwtSecret)
export let JWT_SECRET = _jwtSecret;
// Called by the admin rotate-jwt-secret endpoint to update the in-process
// binding that all middleware and route files reference.
export function updateJwtSecret(newSecret: string): void {
JWT_SECRET = newSecret;
}
// ENCRYPTION_KEY is used to derive at-rest encryption keys for stored secrets
// (API keys, MFA TOTP secrets, SMTP password, OIDC client secret, etc.).
@@ -65,55 +93,18 @@ if (_encryptionKey) {
fs.writeFileSync(encKeyFile, _encryptionKey, { mode: 0o600 });
console.log('Encryption key persisted to', encKeyFile);
} catch (writeErr: unknown) {
console.warn(
'WARNING: Could not persist encryption key to disk:',
writeErr instanceof Error ? writeErr.message : writeErr,
);
console.warn('WARNING: Could not persist encryption key to disk:', writeErr instanceof Error ? writeErr.message : writeErr);
console.warn('Set ENCRYPTION_KEY env var to avoid losing access to encrypted secrets on restart.');
}
}
export const ENCRYPTION_KEY = _encryptionKey;
// JWT_SECRET is always managed by the server — auto-generated on first start and
// persisted to data/.jwt_secret. Use the admin panel to rotate it; do not set it
// via environment variable (env var would override a rotation on next restart).
let _jwtSecret: string;
try {
_jwtSecret = fs.readFileSync(jwtSecretFile, 'utf8').trim();
} catch {
_jwtSecret = crypto.randomBytes(32).toString('hex');
try {
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(jwtSecretFile, _jwtSecret, { mode: 0o600 });
console.log('Generated and saved JWT secret to', jwtSecretFile);
} catch (writeErr: unknown) {
console.warn(
'WARNING: Could not persist JWT secret to disk:',
writeErr instanceof Error ? writeErr.message : writeErr,
);
console.warn('Sessions will reset on server restart.');
}
}
// export let so TypeScript's CJS output keeps exports.JWT_SECRET live
// (generates `exports.JWT_SECRET = JWT_SECRET = newVal` inside updateJwtSecret)
export let JWT_SECRET = _jwtSecret;
// Called by the admin rotate-jwt-secret endpoint to update the in-process
// binding that all middleware and route files reference.
export function updateJwtSecret(newSecret: string): void {
JWT_SECRET = newSecret;
}
// DEFAULT_LANGUAGE sets the language shown on the login page before the user
// selects one. Only applies when the user has no saved language preference.
const rawDefaultLang = process.env.DEFAULT_LANGUAGE?.toLowerCase() || 'en';
if (!SUPPORTED_LANG_CODES.includes(rawDefaultLang)) {
console.warn(
`DEFAULT_LANGUAGE="${rawDefaultLang}" is not supported. Falling back to "en". Supported: ${SUPPORTED_LANG_CODES.join(', ')}`,
);
console.warn(`DEFAULT_LANGUAGE="${rawDefaultLang}" is not supported. Falling back to "en". Supported: ${SUPPORTED_LANG_CODES.join(', ')}`);
}
export const DEFAULT_LANGUAGE = SUPPORTED_LANG_CODES.includes(rawDefaultLang) ? rawDefaultLang : 'en';
@@ -125,13 +116,7 @@ export const DEFAULT_LANGUAGE = SUPPORTED_LANG_CODES.includes(rawDefaultLang) ?
// challenge token or MCP OAuth tokens — those keep their own TTL.
const DEFAULT_SESSION_DURATION = '24h';
const DURATION_UNITS_MS: Record<string, number> = {
ms: 1,
s: 1000,
m: 60_000,
h: 3_600_000,
d: 86_400_000,
w: 604_800_000,
y: 31_557_600_000,
ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000, y: 31_557_600_000,
};
function parseDurationMs(value: string): number | null {
const m = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w|y)?$/i.exec(value.trim());
@@ -143,9 +128,7 @@ function parseDurationMs(value: string): number | null {
const rawSessionDuration = process.env.SESSION_DURATION?.trim() || DEFAULT_SESSION_DURATION;
const parsedSessionMs = parseDurationMs(rawSessionDuration);
if (parsedSessionMs == null) {
console.warn(
`SESSION_DURATION="${rawSessionDuration}" is not a valid duration (use e.g. 1h, 7d, 30d). Falling back to "${DEFAULT_SESSION_DURATION}".`,
);
console.warn(`SESSION_DURATION="${rawSessionDuration}" is not a valid duration (use e.g. 1h, 7d, 30d). Falling back to "${DEFAULT_SESSION_DURATION}".`);
}
/** Human-readable session length actually in effect (for logs/diagnostics). */
export const SESSION_DURATION = parsedSessionMs == null ? DEFAULT_SESSION_DURATION : rawSessionDuration;
@@ -163,13 +146,10 @@ const DEFAULT_SESSION_DURATION_REMEMBER = '30d';
const rawRememberDuration = process.env.SESSION_DURATION_REMEMBER?.trim() || DEFAULT_SESSION_DURATION_REMEMBER;
const parsedRememberMs = parseDurationMs(rawRememberDuration);
if (parsedRememberMs == null) {
console.warn(
`SESSION_DURATION_REMEMBER="${rawRememberDuration}" is not a valid duration (use e.g. 7d, 30d, 90d). Falling back to "${DEFAULT_SESSION_DURATION_REMEMBER}".`,
);
console.warn(`SESSION_DURATION_REMEMBER="${rawRememberDuration}" is not a valid duration (use e.g. 7d, 30d, 90d). Falling back to "${DEFAULT_SESSION_DURATION_REMEMBER}".`);
}
/** Human-readable "remember me" session length actually in effect (for logs/diagnostics). */
export const SESSION_DURATION_REMEMBER =
parsedRememberMs == null ? DEFAULT_SESSION_DURATION_REMEMBER : rawRememberDuration;
export const SESSION_DURATION_REMEMBER = parsedRememberMs == null ? DEFAULT_SESSION_DURATION_REMEMBER : rawRememberDuration;
/** "Remember me" session length in milliseconds — used for the persistent cookie `maxAge`. */
export const SESSION_DURATION_REMEMBER_MS = parsedRememberMs ?? parseDurationMs(DEFAULT_SESSION_DURATION_REMEMBER)!;
/** "Remember me" session length in seconds — passed to `jwt.sign({ expiresIn })`. */
-11
View File
@@ -66,17 +66,6 @@ export function hasTripPermission(action: string, tripId: number | string, userI
return checkPermission(action, userRow?.role ?? 'user', tripOwnerId, userId, tripOwnerId !== userId);
}
/** True when the user has the global admin role (mirrors REST `user.role === 'admin'` gates). */
export function isAdminUser(userId: number): boolean {
const userRow = db.prepare('SELECT role FROM users WHERE id = ?').get(userId) as { role?: string } | undefined;
return userRow?.role === 'admin';
}
/** Error response for admin-only tools, reproducing the REST `{ error: 'Admin access required' }` string. */
export function adminRequired() {
return { content: [{ type: 'text' as const, text: 'Admin access required' }], isError: true };
}
export function ok(data: unknown) {
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
}
+1 -5
View File
@@ -136,11 +136,7 @@ export function registerAtlasTools(server: McpServer, userId: number, scopes: st
async ({ regionCode, regionName, countryCode }) => {
if (isDemoUser(userId)) return demoDenied();
markRegionVisited(userId, regionCode, regionName, countryCode);
const row = listManuallyVisitedRegions(userId).find(r => r.region_code === regionCode);
// Echo in the client-facing shape ({ code, name, ... }) rather than raw DB columns.
const region = row
? { code: row.region_code, name: row.region_name, country_code: row.country_code, manuallyMarked: true }
: undefined;
const region = listManuallyVisitedRegions(userId).find(r => r.region_code === regionCode);
return ok({ region });
}
);
+25 -160
View File
@@ -5,42 +5,18 @@ import { isDemoUser } from '../../services/authService';
import {
createBudgetItem, updateBudgetItem, deleteBudgetItem,
updateMembers as updateBudgetMembers,
toggleMemberPaid, getBudgetItem,
calculateSettlement, listSettlements, createSettlement, updateSettlement, deleteSettlement,
toggleMemberPaid,
} from '../../services/budgetService';
import { getRates } from '../../services/exchangeRateService';
import { getTripOwner, listMembers } from '../../services/tripService';
import {
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_READONLY,
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
} from './_shared';
import { canRead, canWrite } from '../scopes';
/** Reusable Zod shape for the per-payer amounts on a budget item. */
const payersSchema = z.array(z.object({
user_id: z.number().int().positive(),
amount: z.number().nonnegative(),
})).describe('Who actually paid, and how much each paid, in the expense currency. Ask the user; do not guess.');
import { canWrite } from '../scopes';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
/**
* Resolve the equal-split participants for a new budget item. When member_ids is
* omitted, default to the whole trip (owner + all members), deduped reproducing
* the client's own create flow (CostsPanel seeds participants from all members).
* An explicit empty array means "planning-only, no split" and is passed through.
*/
function resolveMemberIds(tripId: number, member_ids?: number[]): number[] | undefined {
if (member_ids !== undefined) return member_ids;
const owner = getTripOwner(tripId);
if (!owner) return undefined;
const { members } = listMembers(tripId, owner.user_id);
return Array.from(new Set([owner.user_id, ...members.map(m => m.id)]));
}
export function registerBudgetTools(server: McpServer, userId: number, scopes: string[] | null): void {
const R = canRead(scopes, 'budget');
const W = canWrite(scopes, 'budget');
if (isAddonEnabled(ADDON_IDS.BUDGET)) {
@@ -49,26 +25,21 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
if (W) server.registerTool(
'create_budget_item',
{
description: 'Add a budget/expense item to a trip. The cost is split equally among member_ids (omit to split across all trip members, or pass [] for a planning-only entry with no split). Use `payers` to record who actually paid and how much. Ask the user which trip members share this expense and who paid — resolve user IDs with list_trip_members — rather than guessing.',
description: 'Add a budget/expense item to a trip.',
inputSchema: {
tripId: z.number().int().positive(),
name: z.string().min(1).max(200),
category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'),
total_price: z.number().nonnegative(),
currency: z.string().max(10).nullable().optional().describe('ISO currency code (e.g. "EUR"); defaults to the trip currency'),
member_ids: z.array(z.number().int().positive()).optional().describe('Trip member user IDs splitting this expense. Omit to split across all trip members (owner + members); pass [] for no split.'),
payers: payersSchema.optional().describe('Who paid how much, in the expense currency. When given, total_price is derived from the sum. Ask the user; do not guess.'),
expense_date: z.string().max(40).nullable().optional().describe('Date the expense occurred, YYYY-MM-DD'),
note: z.string().max(500).optional(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, name, category, total_price, currency, member_ids, payers, expense_date, note }) => {
async ({ tripId, name, category, total_price, note }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
const members = resolveMemberIds(tripId, member_ids);
const item = createBudgetItem(tripId, { category, name, total_price, currency, member_ids: members, payers, expense_date, note });
const item = createBudgetItem(tripId, { category, name, total_price, note });
safeBroadcast(tripId, 'budget:created', { item });
return ok({ item });
}
@@ -100,26 +71,24 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
if (W) server.registerTool(
'update_budget_item',
{
description: 'Update an existing budget/expense item in a trip. You can also re-split it via member_ids and record who actually paid via payers (amounts in the expense currency). When changing who shares an expense or who paid, ask the user rather than guessing; resolve user IDs with list_trip_members.',
description: 'Update an existing budget/expense item in a trip.',
inputSchema: {
tripId: z.number().int().positive(),
itemId: z.number().int().positive(),
name: z.string().min(1).max(200).optional(),
category: z.string().max(100).optional(),
total_price: z.number().nonnegative().optional(),
member_ids: z.array(z.number().int().positive()).optional().describe('Trip member user IDs splitting this expense; replaces the current split. Omit to leave unchanged, pass [] for no split.'),
payers: payersSchema.optional().describe('Replaces who paid how much, in the expense currency. Omit to leave unchanged. Ask the user; do not guess.'),
persons: z.number().int().positive().nullable().optional(),
days: z.number().int().positive().nullable().optional(),
note: z.string().max(500).nullable().optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, itemId, name, category, total_price, member_ids, payers, persons, days, note }) => {
async ({ tripId, itemId, name, category, total_price, persons, days, note }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
const item = updateBudgetItem(itemId, tripId, { name, category, total_price, member_ids, payers, persons, days, note });
const item = updateBudgetItem(itemId, tripId, { name, category, total_price, persons, days, note });
if (!item) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
safeBroadcast(tripId, 'budget:updated', { item });
return ok({ item });
@@ -131,14 +100,14 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
if (W) server.registerTool(
'create_budget_item_with_members',
{
description: 'Create a budget/expense item and set the trip members splitting it in one atomic operation. If userIds is omitted, the cost is split across all trip members; pass an explicit list to split among a subset, or an empty array for a planning-only entry with no split. Ask the user which members share this expense rather than guessing; resolve user IDs with list_trip_members. Only use when the item does not yet exist — if it already exists, use set_budget_item_members directly.',
description: 'Create a budget/expense item and optionally set the trip members splitting it in one atomic operation. If userIds is omitted or empty, behaves like create_budget_item. Only use when the place does not yet exist — if it already exists, use set_budget_item_members directly.',
inputSchema: {
tripId: z.number().int().positive(),
name: z.string().min(1).max(200),
category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'),
total_price: z.number().nonnegative(),
note: z.string().max(500).optional(),
userIds: z.array(z.number().int().positive()).optional().describe('User IDs splitting this item; omit to split across all trip members, or pass an empty array for no split'),
userIds: z.array(z.number().int().positive()).optional().describe('User IDs splitting this item; omit or pass empty array to skip member assignment'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
@@ -146,16 +115,19 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
// Omitted userIds → default to the whole trip, matching create_budget_item.
const members = (userIds && userIds.length > 0) ? userIds : resolveMemberIds(tripId, undefined);
const hasMembers = userIds && userIds.length > 0;
try {
const item = db.transaction(() => {
const created = createBudgetItem(tripId, { category, name, total_price, note, member_ids: members });
return getBudgetItem(created.id, tripId)!;
})();
safeBroadcast(tripId, 'budget:created', { item });
if (members && members.length > 0) safeBroadcast(tripId, 'budget:members-updated', { item });
return ok({ item });
const run = db.transaction(() => {
const item = createBudgetItem(tripId, { category, name, total_price, note });
if (hasMembers) {
return updateBudgetMembers(item.id, tripId, userIds!);
}
return { item };
});
const result = run();
safeBroadcast(tripId, 'budget:created', { item: (result as any).item ?? result });
if (hasMembers) safeBroadcast(tripId, 'budget:members-updated', { item: result });
return ok({ item: result });
} catch {
return { content: [{ type: 'text' as const, text: 'Failed to create budget item.' }], isError: true };
}
@@ -165,7 +137,7 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
if (W) server.registerTool(
'set_budget_item_members',
{
description: 'Set which trip members are splitting a budget item (replaces current member list). Ask the user which members share the expense; resolve user IDs with list_trip_members.',
description: 'Set which trip members are splitting a budget item (replaces current member list).',
inputSchema: {
tripId: z.number().int().positive(),
itemId: z.number().int().positive(),
@@ -177,9 +149,7 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
const result = updateBudgetMembers(itemId, tripId, userIds);
if (!result) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
const item = getBudgetItem(itemId, tripId);
const item = updateBudgetMembers(itemId, tripId, userIds);
safeBroadcast(tripId, 'budget:members-updated', { item });
return ok({ item });
}
@@ -206,110 +176,5 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
return ok({ member });
}
);
// --- SETTLEMENTS (settle-up payments between members) ---
if (R) server.registerTool(
'get_settlement_summary',
{
description: "See each member's net balance and the suggested payments to settle shared expenses. Amounts are in the trip's base currency. Call this before recording a settlement so you know who should pay whom and how much.",
inputSchema: {
tripId: z.number().int().positive(),
base: z.string().max(10).optional().describe('ISO currency code to compute balances in; defaults to the trip currency'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ tripId, base }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
const trip = db.prepare('SELECT currency FROM trips WHERE id = ?').get(tripId) as { currency?: string } | undefined;
const tripCurrency = trip?.currency || 'EUR';
const effectiveBase = (base || tripCurrency).toUpperCase();
const rates = await getRates(effectiveBase);
const summary = calculateSettlement(tripId, { base: effectiveBase, rates, tripCurrency });
return ok({ summary });
}
);
if (R) server.registerTool(
'list_settlements',
{
description: 'List the recorded settle-up payments for a trip (who paid whom, how much, when).',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
return ok({ settlements: listSettlements(tripId) });
}
);
if (W) server.registerTool(
'create_settlement',
{
description: "Record a settle-up payment: from_user_id paid to_user_id the given amount (in the trip's base currency) to settle shared expenses. Use get_settlement_summary first to find who owes whom and how much.",
inputSchema: {
tripId: z.number().int().positive(),
from_user_id: z.number().int().positive().describe('User ID of the member who paid'),
to_user_id: z.number().int().positive().describe('User ID of the member who received the payment'),
amount: z.number().positive().describe("Amount paid, in the trip's base currency"),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, from_user_id, to_user_id, amount }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
const settlement = createSettlement(tripId, { from_user_id, to_user_id, amount }, userId);
safeBroadcast(tripId, 'budget:settlement-created', { settlement });
return ok({ settlement });
}
);
if (W) server.registerTool(
'update_settlement',
{
description: 'Update a recorded settle-up payment (who paid, who received, and the amount).',
inputSchema: {
tripId: z.number().int().positive(),
settlementId: z.number().int().positive(),
from_user_id: z.number().int().positive().describe('User ID of the member who paid'),
to_user_id: z.number().int().positive().describe('User ID of the member who received the payment'),
amount: z.number().positive().describe("Amount paid, in the trip's base currency"),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, settlementId, from_user_id, to_user_id, amount }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
const settlement = updateSettlement(settlementId, tripId, { from_user_id, to_user_id, amount });
if (!settlement) return { content: [{ type: 'text' as const, text: 'Settlement not found.' }], isError: true };
safeBroadcast(tripId, 'budget:settlement-updated', { settlement });
return ok({ settlement });
}
);
if (W) server.registerTool(
'delete_settlement',
{
description: 'Delete a recorded settle-up payment. This is the undo for create_settlement and restores the affected balances.',
inputSchema: {
tripId: z.number().int().positive(),
settlementId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId, settlementId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
const deleted = deleteSettlement(settlementId, tripId);
if (!deleted) return { content: [{ type: 'text' as const, text: 'Settlement not found.' }], isError: true };
safeBroadcast(tripId, 'budget:settlement-deleted', { settlementId });
return ok({ success: true });
}
);
} // isAddonEnabled(BUDGET)
}
+6 -9
View File
@@ -99,20 +99,19 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
start_day_id: z.number().int().positive().describe('Check-in day ID'),
end_day_id: z.number().int().positive().describe('Check-out day ID'),
check_in: z.string().max(10).optional().describe('Check-in time e.g. "15:00"'),
check_in_end: z.string().max(10).optional().describe('Check-in window end time e.g. "20:00"'),
check_out: z.string().max(10).optional().describe('Check-out time e.g. "11:00"'),
confirmation: z.string().max(100).optional(),
notes: z.string().max(1000).optional(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes }) => {
async ({ tripId, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
const errors = validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id);
if (errors.length > 0) return { content: [{ type: 'text' as const, text: errors.map(e => e.message).join(', ') }], isError: true };
const accommodation = createAccommodation(tripId, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes });
const accommodation = createAccommodation(tripId, { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes });
safeBroadcast(tripId, 'accommodation:created', { accommodation });
return ok({ accommodation });
}
@@ -138,7 +137,6 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
start_day_id: z.number().int().positive().describe('Check-in day ID'),
end_day_id: z.number().int().positive().describe('Check-out day ID'),
check_in: z.string().max(10).optional().describe('Check-in time e.g. "15:00"'),
check_in_end: z.string().max(10).optional().describe('Check-in window end time e.g. "20:00"'),
check_out: z.string().max(10).optional().describe('Check-out time e.g. "11:00"'),
confirmation: z.string().max(100).optional(),
accommodation_notes: z.string().max(1000).optional().describe('Notes for the accommodation'),
@@ -147,7 +145,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, accommodation_notes, price, currency }) => {
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, start_day_id, end_day_id, check_in, check_out, confirmation, accommodation_notes, price, currency }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
@@ -156,7 +154,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
try {
const run = db.transaction(() => {
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone, price, currency });
const accommodation = createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes: accommodation_notes });
const accommodation = createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_out, confirmation, notes: accommodation_notes });
return { place, accommodation };
});
const result = run();
@@ -180,20 +178,19 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
start_day_id: z.number().int().positive().optional(),
end_day_id: z.number().int().positive().optional(),
check_in: z.string().max(10).optional(),
check_in_end: z.string().max(10).optional().describe('Check-in window end time e.g. "20:00"'),
check_out: z.string().max(10).optional(),
confirmation: z.string().max(100).optional(),
notes: z.string().max(1000).optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, accommodationId, place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes }) => {
async ({ tripId, accommodationId, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
const existing = getAccommodation(accommodationId, tripId);
if (!existing) return { content: [{ type: 'text' as const, text: 'Accommodation not found.' }], isError: true };
const accommodation = updateAccommodation(accommodationId, existing, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes });
const accommodation = updateAccommodation(accommodationId, existing, { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes });
safeBroadcast(tripId, 'accommodation:updated', { accommodation });
return ok({ accommodation });
}
+4 -11
View File
@@ -136,9 +136,7 @@ export function registerJourneyTools(server: McpServer, userId: number, scopes:
async ({ title, subtitle, trip_ids }) => {
if (isDemoUser(userId)) return demoDenied();
const journey = createJourney(userId, { title, subtitle, trip_ids });
// Return the fully-hydrated journey (entries/contributors/trips/stats/my_role),
// matching get_journey, rather than the bare row.
return ok({ journey: getJourneyFull(journey.id, userId) ?? journey });
return ok({ journey });
}
);
@@ -235,9 +233,7 @@ export function registerJourneyTools(server: McpServer, userId: number, scopes:
if (isDemoUser(userId)) return demoDenied();
const entry = createEntry(journeyId, userId, { entry_date, title, story, entry_time, location_name, mood, sort_order });
if (!entry) return notFound('Journey not found or access denied.');
// Return through the listEntries enrichment (parsed tags/pros_cons, photos, source_trip_name).
const enriched = listEntries(journeyId, userId)?.find(e => e.id === entry.id) ?? entry;
return ok({ entry: enriched });
return ok({ entry });
}
);
@@ -259,9 +255,7 @@ export function registerJourneyTools(server: McpServer, userId: number, scopes:
if (isDemoUser(userId)) return demoDenied();
const entry = updateEntry(entryId, userId, { title, story, entry_date, entry_time, mood }, undefined);
if (!entry) return notFound('Entry not found or access denied.');
// Return through the listEntries enrichment (parsed tags/pros_cons, photos), matching create_journey_entry.
const enriched = listEntries(entry.journey_id, userId)?.find(e => e.id === entry.id) ?? entry;
return ok({ entry: enriched });
return ok({ entry });
}
);
@@ -370,8 +364,7 @@ export function registerJourneyTools(server: McpServer, userId: number, scopes:
if (isDemoUser(userId)) return demoDenied();
const result = updateJourneyPreferences(journeyId, userId, { hide_skeletons });
if (!result) return notFound('Journey not found or access denied.');
// Return the service result ({ hide_skeletons }), matching the REST route.
return ok(result);
return ok({ success: true });
}
);
+21 -68
View File
@@ -9,16 +9,15 @@ import {
listBags, createBag, updateBag, deleteBag, setBagMembers,
getCategoryAssignees as getPackingCategoryAssignees,
updateCategoryAssignees as updatePackingCategoryAssignees,
applyTemplate, saveAsTemplate, listTemplates, bulkImport,
applyTemplate, saveAsTemplate, bulkImport,
} from '../../services/packingService';
import {
safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
isAdminUser, adminRequired,
} from './_shared';
import { canRead, canWrite } from '../scopes';
import { isAddonEnabled, deletePackingTemplate } from '../../services/adminService';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
export function registerPackingTools(server: McpServer, userId: number, scopes: string[] | null): void {
@@ -172,9 +171,7 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
// createBag returns a bare row; hydrate with the empty members array that
// listBags and the schema always carry, so the client/AI consumer matches.
const bag = { ...(createBag(tripId, { name, color }) as object), members: [] };
const bag = createBag(tripId, { name, color });
safeBroadcast(tripId, 'packing:bag-created', { bag });
return ok({ bag });
}
@@ -200,10 +197,7 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
const bodyKeys: string[] = [];
if (name !== undefined) { fields.name = name; bodyKeys.push('name'); }
if (color !== undefined) { fields.color = color; bodyKeys.push('color'); }
const updated = updateBag(tripId, bagId, fields, bodyKeys);
if (!updated) return { content: [{ type: 'text' as const, text: 'Bag not found.' }], isError: true };
// Hydrate with the members array (matches create_packing_bag, listBags, and the schema).
const bag = listBags(tripId).find(b => b.id === (updated as { id: number }).id) ?? { ...(updated as object), members: [] };
const bag = updateBag(tripId, bagId, fields, bodyKeys);
safeBroadcast(tripId, 'packing:bag-updated', { bag });
return ok({ bag });
}
@@ -244,10 +238,9 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
const members = setBagMembers(tripId, bagId, userIds);
if (!members) return { content: [{ type: 'text' as const, text: 'Bag not found.' }], isError: true };
safeBroadcast(tripId, 'packing:bag-members-updated', { bagId, members });
return ok({ members });
setBagMembers(tripId, bagId, userIds);
safeBroadcast(tripId, 'packing:bag-members-updated', { bagId, userIds });
return ok({ success: true });
}
);
@@ -282,9 +275,9 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
const assignees = updatePackingCategoryAssignees(tripId, categoryName, userIds);
safeBroadcast(tripId, 'packing:assignees', { category: categoryName, assignees });
return ok({ assignees });
updatePackingCategoryAssignees(tripId, categoryName, userIds);
safeBroadcast(tripId, 'packing:assignees', { categoryName, userIds });
return ok({ success: true });
}
);
@@ -302,32 +295,17 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
const items = applyTemplate(tripId, templateId);
if (items === null) return { content: [{ type: 'text' as const, text: 'Template not found.' }], isError: true };
safeBroadcast(tripId, 'packing:template-applied', { items });
return ok({ items, count: items.length });
}
);
if (R) server.registerTool(
'list_packing_templates',
{
description: 'List the reusable packing templates (id, name, item count) so one can be applied with apply_packing_template.',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
return ok({ templates: listTemplates() });
const applied = applyTemplate(tripId, templateId);
if (applied === null) return { content: [{ type: 'text' as const, text: 'Template not found.' }], isError: true };
safeBroadcast(tripId, 'packing:template-applied', { templateId });
return ok({ success: true });
}
);
if (W) server.registerTool(
'save_packing_template',
{
description: 'Save the current packing list as a reusable template. Returns the new template (id, name, category/item counts). Admin only.',
description: 'Save the current packing list as a reusable template.',
inputSchema: {
tripId: z.number().int().positive(),
templateName: z.string().min(1).max(100),
@@ -338,46 +316,21 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
// Templates are global; the REST route restricts saving to admins. Match it.
if (!isAdminUser(userId)) return adminRequired();
const template = saveAsTemplate(tripId, userId, templateName);
if (!template) return { content: [{ type: 'text' as const, text: 'Nothing to save — the packing list is empty.' }], isError: true };
return ok({ template });
}
);
if (W) server.registerTool(
'delete_packing_template',
{
description: 'Delete a reusable packing template. Templates are global, so deletion is admin only.',
inputSchema: {
templateId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ templateId }) => {
if (isDemoUser(userId)) return demoDenied();
// Templates are global; the REST route restricts management to admins. Match it.
if (!isAdminUser(userId)) return adminRequired();
const result = deletePackingTemplate(String(templateId));
if ('error' in result) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
return ok({ success: true, name: result.name });
saveAsTemplate(tripId, userId, templateName);
return ok({ success: true });
}
);
if (W) server.registerTool(
'bulk_import_packing',
{
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.',
inputSchema: {
tripId: z.number().int().positive(),
items: z.array(z.object({
name: z.string().min(1).max(200),
category: z.string().optional(),
quantity: z.number().int().positive().optional(),
bag: z.string().max(100).optional().describe('Bag name to assign the item to; created if it does not exist'),
weight_grams: z.number().nonnegative().optional(),
checked: z.boolean().optional(),
})).min(1),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
@@ -386,9 +339,9 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
const created = bulkImport(tripId, items);
for (const item of created) safeBroadcast(tripId, 'packing:created', { item });
return ok({ items: created, count: created.length });
bulkImport(tripId, items);
safeBroadcast(tripId, 'packing:updated', {});
return ok({ success: true, count: items.length });
}
);
}
+1 -1
View File
@@ -221,7 +221,7 @@ export function registerReservationTools(server: McpServer, userId: number, scop
safeBroadcast(tripId, isNewAccommodation ? 'accommodation:created' : 'accommodation:updated', {});
safeBroadcast(tripId, 'reservation:updated', { reservation });
return ok({ reservation, accommodation_id: (reservation as any)?.accommodation_id ?? null });
return ok({ reservation, accommodation_id: (reservation as any).accommodation_id });
}
);
}
+6 -58
View File
@@ -4,11 +4,9 @@ import { canAccessTrip } from '../../db/database';
import { isDemoUser } from '../../services/authService';
import {
createReservation, deleteReservation, getReservation, updateReservation,
type EndpointInput,
} from '../../services/reservationService';
import { linkBudgetItemToReservation } from '../../services/budgetService';
import { getDay } from '../../services/dayService';
import { findByIata } from '../../services/airportService';
import {
safeBroadcast, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
TOOL_ANNOTATIONS_WRITE, demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
@@ -17,56 +15,17 @@ import { canWrite } from '../scopes';
const TRANSPORT_TYPES = ['flight', 'train', 'car', 'cruise'] as const;
const endpointObjectSchema = z.object({
const endpointSchema = z.array(z.object({
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")'),
code: z.string().optional().describe('IATA airport code for flights (e.g. "ZRH"). Leave empty for other transport types.'),
lat: z.number().optional().describe('Latitude. For flights, leave empty and set code instead — coordinates are filled from the airport.'),
lng: z.number().optional().describe('Longitude. For flights, leave empty and set code instead — coordinates are filled from the airport.'),
lat: z.number().optional(),
lng: z.number().optional(),
timezone: z.string().optional().describe('IANA timezone (e.g. "Europe/Zurich"). Use airport tz for flights.'),
local_time: z.string().optional().describe('Local departure/arrival time at this endpoint, e.g. "14:35"'),
local_date: z.string().optional().describe('Local date at this endpoint, YYYY-MM-DD'),
});
const endpointSchema = z.array(endpointObjectSchema).optional();
type Endpoint = z.infer<typeof endpointObjectSchema>;
/**
* Endpoint coordinates are stored NOT NULL. Callers may supply a flight endpoint
* with only an IATA `code` (the tool description encourages this), so fill missing
* lat/lng/timezone from the airport database. Returns an error string for the first
* endpoint that can't be resolved rather than letting the NOT NULL bind throw.
*
* Normalizes to the service's EndpointInput shape (nullable fields coerced from the
* schema's optionals), so lat/lng are guaranteed present before the insert.
*/
function resolveEndpointCoords(endpoints: Endpoint[] | undefined): { endpoints: EndpointInput[] } | { error: string } {
if (!endpoints) return { endpoints: [] };
const out: EndpointInput[] = [];
for (const e of endpoints) {
const base = {
role: e.role,
sequence: e.sequence,
name: e.name,
code: e.code ?? null,
timezone: e.timezone ?? null,
local_time: e.local_time ?? null,
local_date: e.local_date ?? null,
};
if (e.lat != null && e.lng != null) { out.push({ ...base, lat: e.lat, lng: e.lng }); continue; }
if (e.code) {
const airport = findByIata(e.code);
if (airport) {
out.push({ ...base, lat: airport.lat, lng: airport.lng, timezone: e.timezone ?? airport.tz });
continue;
}
return { error: `Could not resolve airport code "${e.code}". Use search_airports to find a valid IATA code, or supply lat/lng directly.` };
}
return { error: `Endpoint "${e.name}" is missing coordinates. For flights set "code" to the IATA airport code; for other transport types supply lat/lng.` };
}
return { endpoints: out };
}
})).optional();
export function registerTransportTools(server: McpServer, userId: number, scopes: string[] | null): void {
if (!canWrite(scopes, 'reservations')) return;
@@ -104,9 +63,6 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
if (end_day_id && !getDay(end_day_id, tripId))
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
const resolved = resolveEndpointCoords(endpoints);
if ('error' in resolved) return { content: [{ type: 'text' as const, text: resolved.error }], isError: true };
const meta: Record<string, string> = { ...(metadata ?? {}) };
if (price != null) meta.price = String(price);
@@ -122,7 +78,7 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
end_day_id: end_day_id ?? start_day_id,
status: status ?? 'pending',
metadata: Object.keys(meta).length > 0 ? meta : undefined,
endpoints: resolved.endpoints,
endpoints,
needs_review,
});
@@ -179,14 +135,6 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
if (end_day_id && !getDay(end_day_id, tripId))
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
// Only resolve when endpoints are explicitly provided; undefined leaves them untouched.
let resolvedEndpoints: EndpointInput[] | undefined;
if (endpoints !== undefined) {
const resolved = resolveEndpointCoords(endpoints);
if ('error' in resolved) return { content: [{ type: 'text' as const, text: resolved.error }], isError: true };
resolvedEndpoints = resolved.endpoints;
}
const { reservation } = updateReservation(reservationId, tripId, {
title,
type,
@@ -198,7 +146,7 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
end_day_id,
status,
metadata,
endpoints: resolvedEndpoints,
endpoints,
needs_review,
}, existing);
safeBroadcast(tripId, 'reservation:updated', { reservation });
+3 -6
View File
@@ -55,10 +55,8 @@ export function registerVacayTools(server: McpServer, userId: number, scopes: st
async ({ block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
// updatePlan already returns the fully-hydrated { plan }; surface it so the
// AI consumer sees the updated plan, matching get_vacay_plan.
const result = await updatePlan(planId, { block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled }, undefined);
return ok(result);
await updatePlan(planId, { block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled }, undefined);
return ok({ success: true });
}
);
@@ -75,8 +73,7 @@ export function registerVacayTools(server: McpServer, userId: number, scopes: st
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
setUserColor(userId, planId, color, undefined);
// Echo the persisted color (mirrors the service default) so the AI consumer sees what was set.
return ok({ success: true, color: color || '#6366f1' });
return ok({ success: true });
}
);
@@ -78,8 +78,6 @@ export interface AirtrailFlightRaw {
datePrecision: string | null;
departure: string | null;
arrival: string | null;
departureScheduled: string | null;
arrivalScheduled: string | null;
airline: AirtrailNamedCode | null;
flightNumber: string | null;
aircraft: AirtrailNamedCode | null;
@@ -94,14 +92,10 @@ export interface AirtrailSavePayload {
id?: number;
from: string;
to: string;
departure: string | null;
departure: string;
departureTime?: string | null;
arrival?: string | null;
arrivalTime?: string | null;
departureScheduled?: string | null;
departureScheduledTime?: string | null;
arrivalScheduled?: string | null;
arrivalScheduledTime?: string | null;
datePrecision?: string;
airline?: string | null;
flightNumber?: string | null;
+8 -11
View File
@@ -55,8 +55,8 @@ export function normalizeFlight(raw: AirtrailFlightRaw): AirtrailFlight {
toCode: airportCode(raw.to),
toName: raw.to?.name ?? null,
date: raw.date ?? null,
departure: raw.departureScheduled ?? null,
arrival: raw.arrivalScheduled ?? null,
departure: raw.departure ?? null,
arrival: raw.arrival ?? null,
airline: entityCode(raw.airline),
flightNumber: raw.flightNumber ?? null,
aircraft: entityCode(raw.aircraft),
@@ -94,17 +94,14 @@ function hasCoords(a: AirtrailAirport | null): a is AirtrailAirport & { lat: num
/** Raw AirTrail flight → the data createReservation() expects (type:'flight'). */
export function mapFlightToReservation(raw: AirtrailFlightRaw): MappedReservation {
// Read the SCHEDULED times only — TREK plans against the scheduled (booked) time,
// not the actual/estimated `departure`/`arrival`. When a flight has no scheduled
// time, the clock is left blank (date preserved) rather than fabricated.
const dep = localParts(raw.departureScheduled, raw.from?.tz ?? null);
const arr = localParts(raw.arrivalScheduled, raw.to?.tz ?? null);
const dep = localParts(raw.departure, raw.from?.tz ?? null);
const arr = localParts(raw.arrival, raw.to?.tz ?? null);
const fromCode = airportCode(raw.from);
const toCode = airportCode(raw.to);
const datePrefix = raw.date || dep.date;
const reservation_time = dep.date && dep.time ? `${dep.date}T${dep.time}` : (datePrefix ?? null);
const reservation_end_time = arr.date && arr.time ? `${arr.date}T${arr.time}` : null;
const reservation_time = datePrefix ? `${datePrefix}T${dep.time ?? '00:00'}` : null;
const reservation_end_time = arr.date ? `${arr.date}T${arr.time ?? '00:00'}` : null;
const endpoints: MappedEndpoint[] = [];
let needsReview = raw.datePrecision && raw.datePrecision !== 'day' ? 1 : 0;
@@ -181,8 +178,8 @@ export function canonicalHash(raw: AirtrailFlightRaw): string {
to: airportCode(raw.to),
date: raw.date ?? null,
datePrecision: raw.datePrecision ?? 'day',
departureScheduled: raw.departureScheduled ?? null,
arrivalScheduled: raw.arrivalScheduled ?? null,
departure: raw.departure ?? null,
arrival: raw.arrival ?? null,
airline: entityCode(raw.airline),
flightNumber: raw.flightNumber ?? null,
aircraft: entityCode(raw.aircraft),
@@ -207,13 +207,6 @@ export function buildSavePayload(reservation: any, existing: AirtrailFlightRaw):
departureTime: dep.time,
arrival: arr.date,
arrivalTime: arr.time,
// Import reads the SCHEDULED time, so a TREK edit must write back there too —
// otherwise the next pull (scheduled-wins) would revert it. AirTrail rebuilds the
// instant from a full-ISO date carrier + the HH:MM time, so pass a date carrier.
departureScheduled: dep.date ? `${dep.date}T00:00:00.000Z` : null,
departureScheduledTime: dep.time,
arrivalScheduled: arr.date ? `${arr.date}T00:00:00.000Z` : null,
arrivalScheduledTime: arr.time,
// These are AirTrail-owned details TREK doesn't surface in its edit UI — a TREK
// edit can leave them out of `metadata`. Preserve AirTrail's current value when
// TREK has none rather than nulling it out (#1240). entityCode mirrors the
-9
View File
@@ -158,15 +158,6 @@ export function createBudgetItem(
return item;
}
/** Fetch a single budget item hydrated with its members and payers, scoped to the trip. */
export function getBudgetItem(id: string | number, tripId: string | number): BudgetItem | null {
const item = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId) as BudgetItem | undefined;
if (!item) return null;
item.members = loadItemMembers(id);
item.payers = loadItemPayers(id);
return item;
}
export function linkBudgetItemToReservation(
tripId: string | number,
reservationId: number,
+31 -45
View File
@@ -986,73 +986,59 @@ export async function reverseGeocode(lat: string, lng: string, lang?: string): P
export async function resolveGoogleMapsUrl(url: string): Promise<{ lat: number; lng: number; name: string | null; address: string | null }> {
let resolvedUrl = url;
// Extract coordinates from a string (URL or page body). Google Maps encodes
// them several ways: /@lat,lng,zoom · !3dlat!4dlng (map data param) · ?q=/?ll=.
const extractCoords = (s: string): { lat: number; lng: number } | null => {
const at = s.match(/@(-?\d+\.\d+),(-?\d+\.\d+)/);
if (at) return { lat: parseFloat(at[1]), lng: parseFloat(at[2]) };
const data = s.match(/!3d(-?\d+\.\d+)!4d(-?\d+\.\d+)/);
if (data) return { lat: parseFloat(data[1]), lng: parseFloat(data[2]) };
const q = s.match(/[?&](?:q|ll)=(-?\d+\.\d+),(-?\d+\.\d+)/);
if (q) return { lat: parseFloat(q[1]), lng: parseFloat(q[2]) };
return null;
};
const followRedirects = async (target: string, init?: RequestInit): Promise<Response> => {
// Follow redirects for short URLs (goo.gl, maps.app.goo.gl) with SSRF protection.
// Redirects are followed manually so every hop is re-checked — a short link
// that 302s to an internal IP is blocked, while a legitimate cross-host
// redirect (goo.gl → maps.google.com) still resolves.
const parsed = new URL(url);
if (['goo.gl', 'maps.app.goo.gl'].includes(parsed.hostname)) {
try {
return await safeFetchFollow(
target,
{ signal: AbortSignal.timeout(10000), ...init },
const redirectRes = await safeFetchFollow(
url,
{ signal: AbortSignal.timeout(10000) },
{ bypassInternalIpAllowed: true },
);
resolvedUrl = redirectRes.url;
} catch (err) {
if (err instanceof SsrfBlockedError) {
throw Object.assign(new Error('URL blocked by SSRF check'), { status: 403 });
}
throw err;
}
};
// Follow redirects for short URLs (goo.gl, maps.app.goo.gl) and for Google Maps
// URLs that carry no inline coordinates — e.g. ?cid= links (the format
// get_place_details returns) and "Share"-button links. The redirect target
// usually carries the !3d!4d data param we can then parse. Redirects are
// followed manually so every hop is SSRF-re-checked.
const parsed = new URL(url);
const GOOGLE_MAPS_HOSTS = ['goo.gl', 'maps.app.goo.gl', 'google.com', 'www.google.com', 'maps.google.com'];
const isShort = ['goo.gl', 'maps.app.goo.gl'].includes(parsed.hostname);
const isGoogleMaps = GOOGLE_MAPS_HOSTS.includes(parsed.hostname);
if (isShort || (isGoogleMaps && !extractCoords(url))) {
resolvedUrl = (await followRedirects(url)).url || resolvedUrl;
}
let coords = extractCoords(resolvedUrl);
// Extract coordinates from Google Maps URL patterns:
// /@48.8566,2.3522,15z or /place/.../@48.8566,2.3522
// ?q=48.8566,2.3522 or ?ll=48.8566,2.3522
let lat: number | null = null;
let lng: number | null = null;
let placeName: string | null = null;
// Still nothing (e.g. a cid page whose final URL lacks coordinates): fetch the
// page body once and parse the coordinates out of the embedded map data.
if (!coords) {
try {
const pageRes = await followRedirects(resolvedUrl, {
headers: { 'User-Agent': 'TREK-Travel-Planner/1.0' },
});
coords = extractCoords(await pageRes.text());
} catch (err) {
if ((err as { status?: number })?.status === 403) throw err; // SSRF block — surface it
// Otherwise fall through to the not-found error below.
}
// Pattern: /@lat,lng
const atMatch = resolvedUrl.match(/@(-?\d+\.?\d*),(-?\d+\.?\d*)/);
if (atMatch) { lat = parseFloat(atMatch[1]); lng = parseFloat(atMatch[2]); }
// Pattern: !3dlat!4dlng (Google Maps data params)
if (!lat) {
const dataMatch = resolvedUrl.match(/!3d(-?\d+\.?\d*)!4d(-?\d+\.?\d*)/);
if (dataMatch) { lat = parseFloat(dataMatch[1]); lng = parseFloat(dataMatch[2]); }
}
// Pattern: ?q=lat,lng or &q=lat,lng
if (!lat) {
const qMatch = resolvedUrl.match(/[?&]q=(-?\d+\.?\d*),(-?\d+\.?\d*)/);
if (qMatch) { lat = parseFloat(qMatch[1]); lng = parseFloat(qMatch[2]); }
}
// Extract place name from URL path: /place/Place+Name/@...
let placeName: string | null = null;
const placeMatch = resolvedUrl.match(/\/place\/([^/@]+)/);
if (placeMatch) {
placeName = decodeURIComponent(placeMatch[1].replace(/\+/g, ' '));
}
if (!coords || isNaN(coords.lat) || isNaN(coords.lng)) {
if (!lat || !lng || isNaN(lat) || isNaN(lng)) {
throw Object.assign(new Error('Could not extract coordinates from URL'), { status: 400 });
}
const { lat, lng } = coords;
// Reverse geocode to get address
const nominatimRes = await fetch(
+2 -8
View File
@@ -17,9 +17,9 @@ export interface ReservationEndpoint {
local_date: string | null;
}
export type EndpointInput = Omit<ReservationEndpoint, 'id' | 'reservation_id' | 'sequence'> & { sequence?: number };
type EndpointInput = Omit<ReservationEndpoint, 'id' | 'reservation_id' | 'sequence'> & { sequence?: number };
export function loadEndpointsByTrip(tripId: string | number): Map<number, ReservationEndpoint[]> {
function loadEndpointsByTrip(tripId: string | number): Map<number, ReservationEndpoint[]> {
const rows = db.prepare(`
SELECT e.* FROM reservation_endpoints e
JOIN reservations r ON e.reservation_id = r.id
@@ -110,9 +110,6 @@ export function listReservations(tripId: string | number) {
for (const r of reservations) {
r.day_positions = posMap.get(r.id) || null;
r.endpoints = endpointsMap.get(r.id) || [];
// accommodation_id is a TEXT column; the integer FK reads back as a numeric
// string (e.g. "14.0"). Normalize to an int so clients can parse it.
r.accommodation_id = r.accommodation_id == null ? null : Math.trunc(Number(r.accommodation_id));
}
return reservations;
@@ -166,9 +163,6 @@ export function getReservationWithJoins(id: string | number) {
`).get(id) as any;
if (!row) return undefined;
row.endpoints = loadEndpoints(row.id);
// accommodation_id is a TEXT column; the integer FK reads back as a numeric
// string (e.g. "14.0"). Normalize to an int so clients can parse it.
row.accommodation_id = row.accommodation_id == null ? null : Math.trunc(Number(row.accommodation_id));
return row;
}
+18 -52
View File
@@ -5,7 +5,7 @@ import { Trip, User } from '../types';
import { listDays, listAccommodations } from './dayService';
import { listBudgetItems } from './budgetService';
import { listItems as listPackingItems } from './packingService';
import { listReservations, loadEndpointsByTrip } from './reservationService';
import { listReservations } from './reservationService';
import { listNotes as listCollabNotes } from './collabService';
import { shiftOwnerEntriesForTripWindow } from './vacayService';
@@ -516,54 +516,27 @@ export function exportICS(tripId: string | number): { ics: string; filename: str
}
}
// Transport/flight reservations carry no top-level reservation_time; their
// times live per endpoint (local_date + local_time) in reservation_endpoints.
const endpointsMap = loadEndpointsByTrip(tripId);
const isDate = (s: string | null | undefined) => !!s && /^\d{4}-\d{2}-\d{2}$/.test(s);
const isTime = (s: string | null | undefined) => !!s && /^\d{2}:\d{2}/.test(s);
// Build the DTSTART/DTEND lines for a reservation, or null when it has no
// calendar-placeable time. Hotels/restaurants use reservation_time; flights
// fall back to their first/last endpoint.
const buildReservationTimeLines = (r: any): string | null => {
if (r.reservation_time) {
const datePart = r.reservation_time.includes('T') ? r.reservation_time.split('T')[0] : r.reservation_time;
if (!isDate(datePart)) return null; // time-only (relative "Day N" trips)
if (r.reservation_time.includes('T')) {
let out = `DTSTART:${fmtDateTime(r.reservation_time)}\r\n`;
if (r.reservation_end_time) {
const endDt = fmtDateTime(r.reservation_end_time, r.reservation_time);
if (endDt.length >= 15) out += `DTEND:${endDt}\r\n`;
}
return out;
}
return `DTSTART;VALUE=DATE:${fmtDate(r.reservation_time)}\r\n`;
}
const eps = endpointsMap.get(r.id);
if (!eps || eps.length === 0) return null;
const ordered = [...eps].sort((a, b) => a.sequence - b.sequence);
const first = ordered[0];
const last = ordered[ordered.length - 1];
if (!isDate(first.local_date)) return null;
if (isTime(first.local_time)) {
let out = `DTSTART:${fmtDateTime(`${first.local_date}T${first.local_time}`)}\r\n`;
if (last !== first && isDate(last.local_date) && isTime(last.local_time)) {
out += `DTEND:${fmtDateTime(`${last.local_date}T${last.local_time}`)}\r\n`;
}
return out;
}
return `DTSTART;VALUE=DATE:${fmtDate(first.local_date)}\r\n`;
};
// Reservations as events
for (const r of reservations) {
const timeLines = buildReservationTimeLines(r);
if (!timeLines) continue;
if (!r.reservation_time) continue;
// Skip time-only values (no calendar date — occurs on relative "Day N" trips)
const hasDate = r.reservation_time.includes('T')
? /^\d{4}-\d{2}-\d{2}$/.test(r.reservation_time.split('T')[0])
: /^\d{4}-\d{2}-\d{2}$/.test(r.reservation_time);
if (!hasDate) continue;
const hasTime = r.reservation_time.includes('T');
const meta = r.metadata ? (typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata) : {};
ics += `BEGIN:VEVENT\r\nUID:${uid(r.id, 'res')}\r\nDTSTAMP:${now}\r\n`;
ics += timeLines;
if (hasTime) {
ics += `DTSTART:${fmtDateTime(r.reservation_time)}\r\n`;
if (r.reservation_end_time) {
const endDt = fmtDateTime(r.reservation_end_time, r.reservation_time);
if (endDt.length >= 15) ics += `DTEND:${endDt}\r\n`;
}
} else {
ics += `DTSTART;VALUE=DATE:${fmtDate(r.reservation_time)}\r\n`;
}
ics += `SUMMARY:${esc(r.title)}\r\n`;
let desc = r.type ? `Type: ${r.type}` : '';
@@ -574,16 +547,9 @@ export function exportICS(tripId: string | number): { ics: string; filename: str
// Multi-leg flight: show the whole route (FRA → BER → HND) on one event.
const stops = [meta.legs[0]?.from, ...meta.legs.map((l: { to?: string }) => l.to)].filter(Boolean);
if (stops.length) desc += `\nRoute: ${stops.join(' → ')}`;
} else if (meta.departure_airport || meta.arrival_airport) {
} else {
if (meta.departure_airport) desc += `\nFrom: ${meta.departure_airport}`;
if (meta.arrival_airport) desc += `\nTo: ${meta.arrival_airport}`;
} else {
// Endpoint-based transport without route metadata: derive it from endpoints.
const eps = endpointsMap.get(r.id);
if (eps && eps.length > 1) {
const stops = [...eps].sort((a, b) => a.sequence - b.sequence).map(e => e.code || e.name).filter(Boolean);
if (stops.length > 1) desc += `\nRoute: ${stops.join(' → ')}`;
}
}
if (meta.train_number) desc += `\nTrain: ${meta.train_number}`;
if (r.notes) desc += `\n${r.notes}`;
@@ -128,12 +128,10 @@ describe('Tool: mark_region_visited', () => {
arguments: { regionCode: 'US-CA', regionName: 'California', countryCode: 'US' },
});
const data = parseToolResult(result) as any;
// Echoed in the client-facing shape ({ code, name, ... }), not raw DB columns.
expect(data.region).toBeDefined();
expect(data.region.code).toBe('US-CA');
expect(data.region.name).toBe('California');
expect(data.region.region_code).toBe('US-CA');
expect(data.region.region_name).toBe('California');
expect(data.region.country_code).toBe('US');
expect(data.region.manuallyMarked).toBe(true);
const row = testDb.prepare('SELECT * FROM visited_regions WHERE user_id = ? AND region_code = ?').get(user.id, 'US-CA');
expect(row).toBeTruthy();
});
@@ -37,7 +37,7 @@ vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createBudgetItem, addTripMember } from '../../helpers/factories';
import { createUser, createTrip, createBudgetItem } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
@@ -70,7 +70,7 @@ async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promis
// ---------------------------------------------------------------------------
describe('Tool: set_budget_item_members', () => {
it('sets members and returns a hydrated item with members/payers', async () => {
it('sets members and broadcasts budget:members-updated', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createBudgetItem(testDb, trip.id, { name: 'Flights', total_price: 500 });
@@ -80,25 +80,11 @@ describe('Tool: set_budget_item_members', () => {
arguments: { tripId: trip.id, itemId: item.id, userIds: [user.id] },
});
const data = parseToolResult(result) as any;
// Regression: returns a hydrated item, not the raw row from updateMembers.
expect(data.item.members.map((m: any) => m.user_id)).toEqual([user.id]);
expect(Array.isArray(data.item.payers)).toBe(true);
expect(data.item).toBeDefined();
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:members-updated', expect.any(Object));
});
});
it('returns an error for an item not in the trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_budget_item_members',
arguments: { tripId: trip.id, itemId: 99999, userIds: [user.id] },
});
expect(result.isError).toBe(true);
});
});
it('empty array clears members', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
@@ -145,58 +131,6 @@ describe('Tool: set_budget_item_members', () => {
});
});
// ---------------------------------------------------------------------------
// create_budget_item_with_members
// ---------------------------------------------------------------------------
describe('Tool: create_budget_item_with_members', () => {
it('assigns the given members and returns a hydrated item', async () => {
const { user } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, user.id);
addTripMember(testDb, trip.id, member.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item_with_members',
arguments: { tripId: trip.id, name: 'Villa', total_price: 800, userIds: [user.id, member.id] },
});
const data = parseToolResult(result) as any;
expect(data.item.members.map((m: any) => m.user_id).sort()).toEqual([user.id, member.id].sort());
expect(data.item.persons).toBe(2);
});
});
// Regression: omitting userIds previously produced an empty-member (unsaveable) entity.
it('defaults to all trip members when userIds omitted', async () => {
const { user } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, user.id);
addTripMember(testDb, trip.id, member.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item_with_members',
arguments: { tripId: trip.id, name: 'Shared cab', total_price: 50 },
});
const data = parseToolResult(result) as any;
expect(data.item.members.map((m: any) => m.user_id).sort()).toEqual([user.id, member.id].sort());
expect(data.item.members.length).toBeGreaterThan(0);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item_with_members',
arguments: { tripId: trip.id, name: 'X', total_price: 1 },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// toggle_budget_member_paid
// ---------------------------------------------------------------------------
@@ -234,115 +168,6 @@ describe('Tool: toggle_budget_member_paid', () => {
});
});
// ---------------------------------------------------------------------------
// Settlements (settle-up payments)
// ---------------------------------------------------------------------------
describe('Settlement tools', () => {
function tripWithTwo() {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, user.id);
addTripMember(testDb, trip.id, other.id);
return { user, other, trip };
}
it('create_settlement records a payment, broadcasts, and is listed', async () => {
const { user, other, trip } = tripWithTwo();
await withHarness(user.id, async (h) => {
const created = await h.client.callTool({
name: 'create_settlement',
arguments: { tripId: trip.id, from_user_id: other.id, to_user_id: user.id, amount: 42.5 },
});
const cData = parseToolResult(created) as any;
expect(cData.settlement.from_user_id).toBe(other.id);
expect(cData.settlement.to_user_id).toBe(user.id);
expect(cData.settlement.amount).toBe(42.5);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:settlement-created', expect.any(Object));
const listed = await h.client.callTool({ name: 'list_settlements', arguments: { tripId: trip.id } });
const lData = parseToolResult(listed) as any;
expect(lData.settlements).toHaveLength(1);
expect(lData.settlements[0].id).toBe(cData.settlement.id);
});
});
it('update_settlement changes the amount; delete_settlement removes it', async () => {
const { user, other, trip } = tripWithTwo();
await withHarness(user.id, async (h) => {
const created = parseToolResult(await h.client.callTool({
name: 'create_settlement',
arguments: { tripId: trip.id, from_user_id: other.id, to_user_id: user.id, amount: 10 },
})) as any;
const id = created.settlement.id;
const updated = parseToolResult(await h.client.callTool({
name: 'update_settlement',
arguments: { tripId: trip.id, settlementId: id, from_user_id: other.id, to_user_id: user.id, amount: 25 },
})) as any;
expect(updated.settlement.amount).toBe(25);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:settlement-updated', expect.any(Object));
const deleted = parseToolResult(await h.client.callTool({
name: 'delete_settlement',
arguments: { tripId: trip.id, settlementId: id },
})) as any;
expect(deleted.success).toBe(true);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:settlement-deleted', expect.any(Object));
const remaining = testDb.prepare('SELECT count(*) as cnt FROM budget_settlements WHERE trip_id = ?').get(trip.id) as any;
expect(remaining.cnt).toBe(0);
});
});
it('update_settlement returns an error when the settlement is missing', async () => {
const { user, other, trip } = tripWithTwo();
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_settlement',
arguments: { tripId: trip.id, settlementId: 99999, from_user_id: other.id, to_user_id: user.id, amount: 5 },
});
expect(result.isError).toBe(true);
});
});
it('create_settlement is denied for a non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_settlement',
arguments: { tripId: trip.id, from_user_id: other.id, to_user_id: other.id, amount: 5 },
});
expect(result.isError).toBe(true);
});
});
it('get_settlement_summary returns balances and flows', async () => {
// Avoid a real exchange-rate network call: force getRates() to fail closed.
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
try {
const { user, other, trip } = tripWithTwo();
// user paid 100 for an item split between both → other owes user 50.
const item = createBudgetItem(testDb, trip.id, { total_price: 100 });
testDb.prepare('INSERT INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0), (?, ?, 0)')
.run(item.id, user.id, item.id, other.id);
testDb.prepare('INSERT INTO budget_item_payers (budget_item_id, user_id, amount) VALUES (?, ?, ?)')
.run(item.id, user.id, 100);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'get_settlement_summary', arguments: { tripId: trip.id } });
const data = parseToolResult(result) as any;
expect(data.summary).toBeDefined();
expect(Array.isArray(data.summary.balances)).toBe(true);
expect(Array.isArray(data.summary.flows)).toBe(true);
});
} finally {
vi.unstubAllGlobals();
}
});
});
// ---------------------------------------------------------------------------
// Per-person resource
// ---------------------------------------------------------------------------
+1 -84
View File
@@ -35,7 +35,7 @@ vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createBudgetItem, addTripMember } from '../../helpers/factories';
import { createUser, createTrip, createBudgetItem } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
@@ -101,89 +101,6 @@ describe('Tool: create_budget_item', () => {
});
});
// Regression for #1244: a naive create must seed members so the client save-gate
// (participants.size > 0) passes — the entry must be saveable, not member-less.
it('defaults members to the trip owner when member_ids omitted (solo trip)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: { tripId: trip.id, name: 'Dinner', total_price: 40 },
});
const data = parseToolResult(result) as any;
expect(data.item.members.map((m: any) => m.user_id)).toEqual([user.id]);
expect(data.item.persons).toBe(1);
// saveable invariant: client requires participants.size > 0
expect(data.item.members.length).toBeGreaterThan(0);
});
});
it('defaults members to all trip members when member_ids omitted (multi-member)', async () => {
const { user } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, user.id);
addTripMember(testDb, trip.id, member.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: { tripId: trip.id, name: 'Group taxi', total_price: 60 },
});
const data = parseToolResult(result) as any;
const ids = data.item.members.map((m: any) => m.user_id).sort();
expect(ids).toEqual([user.id, member.id].sort());
expect(data.item.persons).toBe(2);
});
});
it('respects an explicit member_ids subset', async () => {
const { user } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, user.id);
addTripMember(testDb, trip.id, member.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: { tripId: trip.id, name: 'My snack', total_price: 5, member_ids: [user.id] },
});
const data = parseToolResult(result) as any;
expect(data.item.members.map((m: any) => m.user_id)).toEqual([user.id]);
});
});
it('treats an explicit empty member_ids as a planning-only entry (no split)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: { tripId: trip.id, name: 'Estimate', total_price: 100, member_ids: [] },
});
const data = parseToolResult(result) as any;
expect(data.item.members).toEqual([]);
});
});
it('round-trips currency, expense_date, and payers', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: {
tripId: trip.id, name: 'Museum', total_price: 30, currency: 'EUR',
expense_date: '2026-07-01', payers: [{ user_id: user.id, amount: 30 }],
},
});
const data = parseToolResult(result) as any;
expect(data.item.currency).toBe('EUR');
expect(data.item.expense_date).toBe('2026-07-01');
expect(data.item.payers.map((p: any) => p.user_id)).toEqual([user.id]);
// total_price derives from payer sum
expect(data.item.total_price).toBe(30);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
@@ -168,14 +168,12 @@ describe('Tool: create_accommodation', () => {
start_day_id: day1.id,
end_day_id: day2.id,
check_in: '15:00',
check_in_end: '20:00',
check_out: '11:00',
confirmation: 'CONF123',
},
});
const data = parseToolResult(result) as any;
expect(data.accommodation).toBeDefined();
expect(data.accommodation.check_in_end).toBe('20:00');
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:created', expect.any(Object));
});
});
-146
View File
@@ -1,146 +0,0 @@
/**
* Unit tests for MCP journey write tools focused on response hydration:
* create_journey returns the full journey (entries/contributors/trips/stats/my_role),
* and create_journey_entry returns the enriched entry (parsed tags, photos array).
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock, broadcastToUser: broadcastMock }));
vi.mock('../../../src/services/adminService', async (importOriginal) => {
const original = await importOriginal() as Record<string, unknown>;
return { ...original, isAddonEnabled: vi.fn().mockReturnValue(true) };
});
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
broadcastMock.mockClear();
delete process.env.DEMO_MODE;
});
afterAll(() => {
testDb.close();
});
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
}
describe('Tool: create_journey', () => {
it('returns the fully-hydrated journey, not a bare row', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_journey',
arguments: { title: 'Eurotrip', subtitle: '2026' },
});
const data = parseToolResult(result) as any;
expect(data.journey.title).toBe('Eurotrip');
// hydrated shape from getJourneyFull
expect(Array.isArray(data.journey.entries)).toBe(true);
expect(Array.isArray(data.journey.contributors)).toBe(true);
expect(Array.isArray(data.journey.trips)).toBe(true);
expect(data.journey.stats).toBeDefined();
expect(data.journey.my_role).toBeDefined();
});
});
});
describe('Tool: create_journey_entry', () => {
it('returns the enriched entry with parsed tags and a photos array', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const journey = (parseToolResult(await h.client.callTool({
name: 'create_journey', arguments: { title: 'J' },
})) as any).journey;
const result = await h.client.callTool({
name: 'create_journey_entry',
arguments: { journeyId: journey.id, entry_date: '2026-07-01', title: 'Day 1', story: 'Arrived' },
});
const data = parseToolResult(result) as any;
expect(data.entry.title).toBe('Day 1');
// listEntries enrichment: tags parsed to an array, photos present
expect(Array.isArray(data.entry.tags)).toBe(true);
expect(Array.isArray(data.entry.photos)).toBe(true);
expect(data.entry).toHaveProperty('source_trip_name');
});
});
});
describe('Tool: update_journey_entry', () => {
it('returns the enriched entry (parsed tags, photos array)', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const journey = (parseToolResult(await h.client.callTool({
name: 'create_journey', arguments: { title: 'J' },
})) as any).journey;
const entry = (parseToolResult(await h.client.callTool({
name: 'create_journey_entry', arguments: { journeyId: journey.id, entry_date: '2026-07-01', title: 'Day 1' },
})) as any).entry;
const result = await h.client.callTool({
name: 'update_journey_entry',
arguments: { entryId: entry.id, title: 'Day 1 (edited)' },
});
const data = parseToolResult(result) as any;
expect(data.entry.title).toBe('Day 1 (edited)');
expect(Array.isArray(data.entry.tags)).toBe(true);
expect(Array.isArray(data.entry.photos)).toBe(true);
});
});
});
describe('Tool: update_journey_preferences', () => {
it('returns the updated preference, not { success }', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const journey = (parseToolResult(await h.client.callTool({
name: 'create_journey', arguments: { title: 'J' },
})) as any).journey;
const result = await h.client.callTool({
name: 'update_journey_preferences',
arguments: { journeyId: journey.id, hide_skeletons: true },
});
const data = parseToolResult(result) as any;
expect(data.hide_skeletons).toBe(true);
});
});
});
@@ -39,7 +39,7 @@ vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createAdmin, createTrip, createPackingItem } from '../../helpers/factories';
import { createUser, createTrip, createPackingItem } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
@@ -148,8 +148,6 @@ describe('Tool: create_packing_bag', () => {
const data = parseToolResult(result) as any;
expect(data.bag).toBeDefined();
expect(data.bag.name).toBe('Checked bag');
// hydrated to match listBags/schema, which always carry a members array
expect(data.bag.members).toEqual([]);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:bag-created', expect.any(Object));
});
});
@@ -269,9 +267,8 @@ describe('Tool: set_bag_members', () => {
arguments: { tripId: trip.id, bagId, userIds: [user.id] },
});
const data = parseToolResult(result) as any;
// Returns the hydrated members list (REST parity), not { success }.
expect(data.members.map((m: any) => m.user_id)).toEqual([user.id]);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:bag-members-updated', expect.objectContaining({ members: expect.any(Array) }));
expect(data.success).toBe(true);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:bag-members-updated', expect.any(Object));
});
});
@@ -287,7 +284,7 @@ describe('Tool: set_bag_members', () => {
arguments: { tripId: trip.id, bagId, userIds: [] },
});
const data = parseToolResult(result) as any;
expect(data.members).toEqual([]);
expect(data.success).toBe(true);
});
});
});
@@ -325,9 +322,8 @@ describe('Tool: set_packing_category_assignees', () => {
arguments: { tripId: trip.id, categoryName: 'Clothing', userIds: [user.id] },
});
const data = parseToolResult(result) as any;
// Returns the hydrated assignees list (REST parity), not { success }.
expect(data.assignees.map((a: any) => a.user_id)).toEqual([user.id]);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:assignees', expect.objectContaining({ category: 'Clothing', assignees: expect.any(Array) }));
expect(data.success).toBe(true);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:assignees', expect.any(Object));
});
});
@@ -341,7 +337,7 @@ describe('Tool: set_packing_category_assignees', () => {
arguments: { tripId: trip.id, categoryName: 'Clothing', userIds: [] },
});
const data = parseToolResult(result) as any;
expect(data.assignees).toEqual([]);
expect(data.success).toBe(true);
});
});
@@ -382,36 +378,7 @@ describe('Tool: apply_packing_template', () => {
// ---------------------------------------------------------------------------
describe('Tool: save_packing_template', () => {
it('saves the current packing list as a template for an admin', async () => {
const { user } = createAdmin(testDb);
const trip = createTrip(testDb, user.id);
createPackingItem(testDb, trip.id, { name: 'Toothbrush', category: 'Toiletries' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'save_packing_template',
arguments: { tripId: trip.id, templateName: 'Weekend Trip' },
});
const data = parseToolResult(result) as any;
// Save now returns the new template (with its id) instead of a bare success flag.
expect(data.template).toBeDefined();
expect(Number.isInteger(data.template.id)).toBe(true);
expect(data.template.name).toBe('Weekend Trip');
});
});
it('returns an error when the packing list is empty', async () => {
const { user } = createAdmin(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'save_packing_template',
arguments: { tripId: trip.id, templateName: 'Empty' },
});
expect(result.isError).toBe(true);
});
});
it('denies a non-admin editor (parity with the REST admin gate)', async () => {
it('saves the current packing list as a template', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
createPackingItem(testDb, trip.id, { name: 'Toothbrush', category: 'Toiletries' });
@@ -420,8 +387,8 @@ describe('Tool: save_packing_template', () => {
name: 'save_packing_template',
arguments: { tripId: trip.id, templateName: 'Weekend Trip' },
});
expect(result.isError).toBe(true);
expect((result.content as any)[0].text).toBe('Admin access required');
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
});
});
@@ -439,96 +406,12 @@ describe('Tool: save_packing_template', () => {
});
});
// ---------------------------------------------------------------------------
// list_packing_templates / delete_packing_template
// ---------------------------------------------------------------------------
describe('Tool: list_packing_templates', () => {
it('lists saved templates with their ids and item counts', async () => {
const { user } = createAdmin(testDb);
const trip = createTrip(testDb, user.id);
createPackingItem(testDb, trip.id, { name: 'Toothbrush', category: 'Toiletries' });
await withHarness(user.id, async (h) => {
const saved = parseToolResult(await h.client.callTool({
name: 'save_packing_template',
arguments: { tripId: trip.id, templateName: 'Beach' },
})) as any;
const listed = parseToolResult(await h.client.callTool({
name: 'list_packing_templates',
arguments: { tripId: trip.id },
})) as any;
expect(listed.templates.some((t: any) => t.id === saved.template.id && t.name === 'Beach')).toBe(true);
});
});
it('is available to a non-admin trip member (read-only)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'list_packing_templates',
arguments: { tripId: trip.id },
});
expect(result.isError).toBeFalsy();
const data = parseToolResult(result) as any;
expect(Array.isArray(data.templates)).toBe(true);
});
});
});
describe('Tool: delete_packing_template', () => {
it('removes a template for an admin', async () => {
const { user } = createAdmin(testDb);
const trip = createTrip(testDb, user.id);
createPackingItem(testDb, trip.id, { name: 'Toothbrush', category: 'Toiletries' });
await withHarness(user.id, async (h) => {
const saved = parseToolResult(await h.client.callTool({
name: 'save_packing_template',
arguments: { tripId: trip.id, templateName: 'Ski' },
})) as any;
const id = saved.template.id;
const deleted = parseToolResult(await h.client.callTool({
name: 'delete_packing_template',
arguments: { templateId: id },
})) as any;
expect(deleted.success).toBe(true);
const remaining = testDb.prepare('SELECT count(*) as cnt FROM packing_templates WHERE id = ?').get(id) as any;
expect(remaining.cnt).toBe(0);
});
});
it('denies a non-admin (parity with the REST admin gate)', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'delete_packing_template',
arguments: { templateId: 1 },
});
expect(result.isError).toBe(true);
expect((result.content as any)[0].text).toBe('Admin access required');
});
});
it('returns an error for a missing template', async () => {
const { user } = createAdmin(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'delete_packing_template',
arguments: { templateId: 99999 },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// bulk_import_packing
// ---------------------------------------------------------------------------
describe('Tool: bulk_import_packing', () => {
it('imports multiple packing items, returns them, and broadcasts per item', async () => {
it('imports multiple packing items and count matches', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const items = [
@@ -542,33 +425,9 @@ describe('Tool: bulk_import_packing', () => {
arguments: { tripId: trip.id, items },
});
const data = parseToolResult(result) as any;
// New contract: returns the created items (REST parity), broadcasts packing:created per item.
expect(data.success).toBe(true);
expect(data.count).toBe(items.length);
expect(Array.isArray(data.items)).toBe(true);
expect(data.items).toHaveLength(items.length);
expect(data.items[0].name).toBe('Passport');
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:created', expect.objectContaining({ item: expect.any(Object) }));
expect(broadcastMock).toHaveBeenCalledTimes(items.length);
});
});
it('honors the widened fields (bag, weight_grams, checked)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'bulk_import_packing',
arguments: {
tripId: trip.id,
items: [{ name: 'Tent', category: 'Camping', bag: 'Backpack', weight_grams: 2500, checked: true }],
},
});
const data = parseToolResult(result) as any;
expect(data.count).toBe(1);
const item = data.items[0];
expect(item.weight_grams).toBe(2500);
expect(item.checked).toBe(1);
expect(item.bag_id).toBeTruthy(); // "Backpack" bag was created and assigned
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:updated', expect.any(Object));
});
});
@@ -350,10 +350,6 @@ describe('Tool: link_hotel_accommodation', () => {
const data = parseToolResult(result) as any;
expect(data.reservation.accommodation_id).not.toBeNull();
expect(data.accommodation_id).not.toBeNull();
// accommodation_id must be a clean integer, not a stringified float ("14.0").
expect(typeof data.reservation.accommodation_id).toBe('number');
expect(Number.isInteger(data.reservation.accommodation_id)).toBe(true);
expect(Number.isInteger(data.accommodation_id)).toBe(true);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:created', expect.any(Object));
});
});
@@ -1,201 +0,0 @@
/**
* Unit tests for MCP transport tools: create_transport, update_transport, delete_transport.
* Focus: flight endpoints supplied with only an IATA `code` are backfilled with
* lat/lng/timezone from the airport database (the columns are NOT NULL), and
* endpoints that can't be resolved produce a clean error instead of a SQL crash.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
broadcastMock.mockClear();
delete process.env.DEMO_MODE;
});
afterAll(() => {
testDb.close();
});
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
}
const flightEndpoints = [
{ role: 'from', sequence: 0, name: 'Zurich', code: 'ZRH' },
{ role: 'to', sequence: 1, name: 'Paris CDG', code: 'CDG' },
];
describe('Tool: create_transport', () => {
it('backfills lat/lng/timezone for code-only flight endpoints', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_transport',
arguments: { tripId: trip.id, type: 'flight', title: 'ZRH → CDG', endpoints: flightEndpoints },
});
const data = parseToolResult(result) as any;
const eps = data.reservation.endpoints;
expect(eps).toHaveLength(2);
const from = eps.find((e: any) => e.role === 'from');
expect(typeof from.lat).toBe('number');
expect(typeof from.lng).toBe('number');
expect(from.timezone).toBe('Europe/Zurich');
// persisted NOT NULL columns are populated
const rows = testDb.prepare('SELECT lat, lng FROM reservation_endpoints WHERE reservation_id = ?').all(data.reservation.id) as any[];
expect(rows.every(r => r.lat != null && r.lng != null)).toBe(true);
});
});
it('keeps manually-supplied coordinates and the caller timezone', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_transport',
arguments: {
tripId: trip.id, type: 'train', title: 'Scenic train',
endpoints: [
{ role: 'from', sequence: 0, name: 'Station A', lat: 46.0, lng: 7.0, timezone: 'Europe/Zurich' },
{ role: 'to', sequence: 1, name: 'Station B', lat: 46.5, lng: 7.5 },
],
},
});
const data = parseToolResult(result) as any;
const from = data.reservation.endpoints.find((e: any) => e.role === 'from');
expect(from.lat).toBe(46.0);
expect(from.timezone).toBe('Europe/Zurich');
});
});
it('errors on an unresolvable airport code instead of crashing', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_transport',
arguments: {
tripId: trip.id, type: 'flight', title: 'Bad flight',
endpoints: [{ role: 'from', sequence: 0, name: 'Nowhere', code: 'ZZZ' }],
},
});
expect(result.isError).toBe(true);
expect((result.content as any)[0].text).toContain('ZZZ');
});
});
it('errors on an endpoint missing both coordinates and a code', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_transport',
arguments: {
tripId: trip.id, type: 'car', title: 'Road trip',
endpoints: [{ role: 'from', sequence: 0, name: 'My house' }],
},
});
expect(result.isError).toBe(true);
expect((result.content as any)[0].text).toContain('missing coordinates');
});
});
it('creates a transport with no endpoints', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_transport',
arguments: { tripId: trip.id, type: 'flight', title: 'TBD flight' },
});
const data = parseToolResult(result) as any;
expect(data.reservation.title).toBe('TBD flight');
});
});
});
describe('Tool: update_transport', () => {
it('backfills coords when replacing endpoints', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const created = parseToolResult(await h.client.callTool({
name: 'create_transport',
arguments: { tripId: trip.id, type: 'flight', title: 'F', endpoints: flightEndpoints },
})) as any;
const result = await h.client.callTool({
name: 'update_transport',
arguments: {
tripId: trip.id, reservationId: created.reservation.id,
endpoints: [
{ role: 'from', sequence: 0, name: 'JFK', code: 'JFK' },
{ role: 'to', sequence: 1, name: 'Zurich', code: 'ZRH' },
],
},
});
const data = parseToolResult(result) as any;
const from = data.reservation.endpoints.find((e: any) => e.role === 'from');
expect(from.code).toBe('JFK');
expect(typeof from.lat).toBe('number');
});
});
it('leaves endpoints untouched when not provided', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const created = parseToolResult(await h.client.callTool({
name: 'create_transport',
arguments: { tripId: trip.id, type: 'flight', title: 'F', endpoints: flightEndpoints },
})) as any;
const result = await h.client.callTool({
name: 'update_transport',
arguments: { tripId: trip.id, reservationId: created.reservation.id, status: 'confirmed' },
});
const data = parseToolResult(result) as any;
expect(data.reservation.status).toBe('confirmed');
expect(data.reservation.endpoints).toHaveLength(2);
});
});
});
+4 -11
View File
@@ -49,9 +49,7 @@ vi.mock('../../../src/services/vacayService', async (importOriginal) => {
const original = await importOriginal() as Record<string, unknown>;
return {
...original,
updatePlan: vi.fn().mockResolvedValue({
plan: { id: 1, block_weekends: true, holidays_enabled: false, company_holidays_enabled: false, carry_over_enabled: false, holiday_calendars: [] },
}),
updatePlan: vi.fn().mockResolvedValue(undefined),
getCountries: vi.fn().mockResolvedValue({ data: [{ code: 'US', name: 'United States' }] }),
getHolidays: vi.fn().mockResolvedValue({ data: [{ date: '2025-01-01', name: 'New Year' }] }),
};
@@ -108,7 +106,7 @@ describe('Tool: get_vacay_plan', () => {
// ---------------------------------------------------------------------------
describe('Tool: update_vacay_plan', () => {
it('calls updatePlan and returns the hydrated plan', async () => {
it('calls updatePlan and returns success', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
@@ -116,11 +114,7 @@ describe('Tool: update_vacay_plan', () => {
arguments: { block_weekends: true, holidays_enabled: false },
});
const data = parseToolResult(result) as any;
// Now returns the fully-hydrated plan (matching get_vacay_plan), not { success }.
expect(data.plan).toBeDefined();
expect(data.plan.block_weekends).toBe(true);
expect(data.plan.holidays_enabled).toBe(false);
expect(Array.isArray(data.plan.holiday_calendars)).toBe(true);
expect(data.success).toBe(true);
});
});
@@ -142,10 +136,9 @@ describe('Tool: set_vacay_color', () => {
it('updates color and returns success', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'set_vacay_color', arguments: { color: '#ff0000' } });
const result = await h.client.callTool({ name: 'set_vacay_color', arguments: { color: '#6366f1' } });
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(data.color).toBe('#ff0000'); // echoes the persisted color
});
});
@@ -23,11 +23,8 @@ function flight(over: Partial<AirtrailFlightRaw> = {}): AirtrailFlightRaw {
to: airport({ id: 2, icao: 'EGLL', iata: 'LHR', name: 'London Heathrow', lat: 51.4706, lon: -0.4619, tz: 'Europe/London' }),
date: '2021-09-01',
datePrecision: 'day',
// Actual times (delayed) — TREK must IGNORE these and read the scheduled times.
departure: '2021-09-01T23:42:00.000+00:00',
arrival: '2021-09-02T07:42:00.000+00:00',
departureScheduled: '2021-09-01T23:00:00.000+00:00', // 19:00 local at JFK (EDT, UTC-4)
arrivalScheduled: '2021-09-02T07:00:00.000+00:00', // 08:00 local at LHR (BST, UTC+1)
departure: '2021-09-01T23:00:00.000+00:00', // 19:00 local at JFK (EDT, UTC-4)
arrival: '2021-09-02T07:00:00.000+00:00', // 08:00 local at LHR (BST, UTC+1)
airline: { id: 1, icao: 'BAW', iata: 'BA', name: 'British Airways' },
flightNumber: 'BA178',
aircraft: { id: 1, icao: 'B772', name: 'Boeing 777' },
@@ -51,9 +48,6 @@ describe('airtrailMapper.normalizeFlight', () => {
flightNumber: 'BA178',
seatClass: 'economy',
});
// The picker preview surfaces the scheduled times, not the actual ones.
expect(n.departure).toBe('2021-09-01T23:00:00.000+00:00');
expect(n.arrival).toBe('2021-09-02T07:00:00.000+00:00');
});
it('falls back to ICAO when IATA is missing and tolerates null airports', () => {
@@ -65,24 +59,14 @@ describe('airtrailMapper.normalizeFlight', () => {
});
describe('airtrailMapper.mapFlightToReservation', () => {
it('composes airport-local times from the SCHEDULED instant + airport tz', () => {
it('composes airport-local times from the instant + airport tz', () => {
const m = mapFlightToReservation(flight());
// Scheduled 23:00 UTC at JFK in September is 19:00 EDT; date stays the AirTrail local date.
// (The actual times in the fixture are 23:42/07:42 — proving they are ignored.)
// 23:00 UTC at JFK in September is 19:00 EDT; date stays the AirTrail local date.
expect(m.reservation_time).toBe('2021-09-01T19:00');
// Scheduled 07:00 UTC at LHR in September is 08:00 BST.
// 07:00 UTC at LHR in September is 08:00 BST.
expect(m.reservation_end_time).toBe('2021-09-02T08:00');
});
it('leaves the clock blank (date only) when the flight has no scheduled time', () => {
const m = mapFlightToReservation(flight({ departureScheduled: null, arrivalScheduled: null }));
// Date is preserved from the AirTrail canonical date; no fabricated 00:00.
expect(m.reservation_time).toBe('2021-09-01');
expect(m.reservation_end_time).toBeNull();
expect(m.endpoints.find(e => e.role === 'from')?.local_time).toBeNull();
expect(m.endpoints.find(e => e.role === 'to')?.local_time).toBeNull();
});
it('builds two endpoints with codes, coords and timezones', () => {
const m = mapFlightToReservation(flight());
expect(m.endpoints).toHaveLength(2);
@@ -135,8 +119,8 @@ describe('airtrailMapper.mapFlightToReservation', () => {
expect(m.endpoints.find(e => e.role === 'to')).toBeDefined();
});
it('leaves the end time null for a partial flight with no scheduled arrival', () => {
const m = mapFlightToReservation(flight({ arrivalScheduled: null }));
it('leaves the end time null for a partial flight with no arrival', () => {
const m = mapFlightToReservation(flight({ arrival: null }));
expect(m.reservation_end_time).toBeNull();
expect(m.reservation_time).toBe('2021-09-01T19:00');
});
@@ -152,17 +136,6 @@ describe('airtrailMapper.canonicalHash', () => {
expect(canonicalHash(flight())).not.toBe(canonicalHash(flight({ note: 'aisle seat' })));
});
it('tracks the scheduled time and ignores actual-time changes', () => {
// A scheduled-time change is what TREK imports, so it must re-sync...
expect(canonicalHash(flight())).not.toBe(
canonicalHash(flight({ departureScheduled: '2021-09-01T22:00:00.000+00:00' })),
);
// ...but a change to the actual time alone must not (TREK never shows it).
expect(canonicalHash(flight())).toBe(
canonicalHash(flight({ departure: '2021-09-01T20:00:00.000+00:00', arrival: '2021-09-02T05:00:00.000+00:00' })),
);
});
it('is independent of seat ordering', () => {
const a = flight({
seats: [
@@ -79,6 +79,10 @@ describe('airtrailSync.buildSavePayload', () => {
const payload = buildSavePayload(reservation(), existingFlight());
expect(payload).not.toBeNull();
expect(payload).toMatchObject({
departureScheduled: '2021-09-01',
departureScheduledTime: '18:45',
arrivalScheduled: '2021-09-02',
arrivalScheduledTime: '08:10',
takeoffActual: '2021-09-01',
takeoffActualTime: '19:12',
landingActual: '2021-09-02',
@@ -92,26 +96,6 @@ describe('airtrailSync.buildSavePayload', () => {
});
});
it('writes the TREK time to the SCHEDULED fields so it round-trips on the next pull', () => {
// Import reads the scheduled time, so a TREK edit must be pushed back there
// (mirroring the read), overwriting AirTrail's stored scheduled value.
const payload = buildSavePayload(reservation(), existingFlight());
expect(payload).toMatchObject({
departureScheduled: '2021-09-01T00:00:00.000Z',
departureScheduledTime: '19:00',
arrivalScheduled: '2021-09-02T00:00:00.000Z',
arrivalScheduledTime: '08:00',
});
});
it('blanks the scheduled time when the TREK reservation has only a date', () => {
const payload = buildSavePayload(reservation({ reservation_time: '2021-09-01', reservation_end_time: null }), existingFlight());
// A date carrier with no HH:MM leaves AirTrail's scheduled instant unset.
expect(payload?.departureScheduledTime).toBeNull();
expect(payload?.arrivalScheduled).toBeNull();
expect(payload?.arrivalScheduledTime).toBeNull();
});
it('preserves a non-day date precision instead of resetting it to day', () => {
const payload = buildSavePayload(reservation(), existingFlight({ datePrecision: 'month' }));
expect(payload?.datePrecision).toBe('month');
@@ -132,8 +116,6 @@ describe('airtrailSync.buildSavePayload', () => {
to: 'LHR',
departure: '2021-09-01',
departureTime: '20:30',
departureScheduled: '2021-09-01T00:00:00.000Z',
departureScheduledTime: '20:30',
flightNumber: 'BA999',
note: 'changed in TREK',
});
@@ -332,41 +332,6 @@ describe('resolveGoogleMapsUrl coordinate extraction (ReDoS guards)', () => {
expect(result.name).toBe('Eiffel Tower');
});
it('MAPS-CID-001: resolves a cid= URL by following the redirect to a coordinate URL', async () => {
// cid URLs (what get_place_details returns, and Google "Share" links) carry no
// inline coords; the redirect target carries the !3d!4d data param.
const fetchMock = vi.fn(async (u: string) => {
if (u.includes('nominatim')) {
return { ok: true, json: async () => ({ display_name: 'Paris, France', name: 'Eiffel Tower', address: {} }) };
}
return { url: 'https://www.google.com/maps/place/Eiffel+Tower/data=!3d48.8584!4d2.2945', text: async () => '' };
});
vi.stubGlobal('fetch', fetchMock);
const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService');
const result = await resolveGoogleMapsUrl('https://maps.google.com/?cid=1234567890');
expect(result.lat).toBeCloseTo(48.8584, 3);
expect(result.lng).toBeCloseTo(2.2945, 3);
});
it('MAPS-CID-002: falls back to parsing coordinates from the page body', async () => {
const fetchMock = vi.fn(async (u: string) => {
if (u.includes('nominatim')) {
return { ok: true, json: async () => ({ display_name: 'NYC, USA', name: null, address: {} }) };
}
if (u.includes('cid=')) {
// Redirect target has no inline coords.
return { url: 'https://www.google.com/maps/place/Somewhere', text: async () => '' };
}
// Body fetch of the resolved URL embeds coords in the map data.
return { url: 'https://www.google.com/maps/place/Somewhere', text: async () => 'x!3d40.6892!4d-74.0445y' };
});
vi.stubGlobal('fetch', fetchMock);
const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService');
const result = await resolveGoogleMapsUrl('https://www.google.com/maps?cid=999');
expect(result.lat).toBeCloseTo(40.6892, 3);
expect(result.lng).toBeCloseTo(-74.0445, 3);
});
it('MAPS-024 (ReDoS): /@(-?\\d+\\.?\\d*),(-?\\d+\\.?\\d*)/ on adversarial input < 500ms', () => {
const adversarial = '/@' + '1'.repeat(10000) + '.';
const start = Date.now();
@@ -397,46 +397,6 @@ describe('exportICS', () => {
expect(ics).toContain('DTEND:20250602T160000');
});
it('TRIP-SVC-010: flight with endpoint times but no reservation_time is included', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris Trip' });
const reservation = createReservation(testDb, trip.id, {
title: 'CDG → JFK',
type: 'flight',
});
// Confirmed flights store times per endpoint, never as reservation_time.
testDb.prepare('UPDATE reservations SET reservation_time=NULL, reservation_end_time=NULL WHERE id=?').run(reservation.id);
const insertEp = testDb.prepare(
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
insertEp.run(reservation.id, 'from', 0, 'Paris CDG', 'CDG', 49.0, 2.5, 'Europe/Paris', '09:00', '2025-06-02');
insertEp.run(reservation.id, 'to', 1, 'New York JFK', 'JFK', 40.6, -73.8, 'America/New_York', '12:00', '2025-06-02');
const { ics } = exportICS(trip.id);
expect(ics).toContain('SUMMARY:CDG → JFK');
expect(ics).toContain('DTSTART:20250602T090000');
expect(ics).toContain('DTEND:20250602T120000');
expect(ics).toContain('Route: CDG → JFK');
});
it('TRIP-SVC-011: flight endpoint with no local_date is skipped (relative Day-N trips)', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Relative Trip' });
const reservation = createReservation(testDb, trip.id, {
title: 'Timeless Flight',
type: 'flight',
});
testDb.prepare('UPDATE reservations SET reservation_time=NULL WHERE id=?').run(reservation.id);
testDb.prepare(
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(reservation.id, 'from', 0, 'Origin', 'AAA', 1.0, 1.0, null, '09:00', null);
const { ics } = exportICS(trip.id);
expect(ics).not.toContain('SUMMARY:Timeless Flight');
});
});
// ── deleteOldCover — path containment ──────────────────────────────────────────
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/shared",
"version": "3.1.1",
"version": "3.1.0",
"private": true,
"description": "Shared API contracts (Zod schemas) — single source of truth for TREK server and client.",
"type": "module",