mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 06:41:46 +00:00
Compare commits
24 Commits
v3-1-2
..
a63e16fb65
| Author | SHA1 | Date | |
|---|---|---|---|
| a63e16fb65 | |||
| dfe98a057c | |||
| d5850041a7 | |||
| ad6e1ddcc8 | |||
| 66f661e2a1 | |||
| 17b4f72be6 | |||
| 7aefeb4c53 | |||
| 63fb5a9c89 | |||
| 17245c5a8c | |||
| 6ab4989c38 | |||
| ea7f7fd9f3 | |||
| 00738c8dbc | |||
| 438f71bbc6 | |||
| c15c89ca61 | |||
| f98058a3af | |||
| 39a3ee7ce7 | |||
| e09849d5b4 | |||
| b3fc5411ca | |||
| f524909008 | |||
| 264cf7d384 | |||
| cb7ce7f229 | |||
| d40c5ce7a6 | |||
| 2d79254c33 | |||
| e6fcbc7789 |
@@ -32,7 +32,6 @@ server/tests/
|
||||
server/vitest.config.ts
|
||||
server/reset-admin.js
|
||||
**/*.test.ts
|
||||
**/*.spec.ts
|
||||
wiki/
|
||||
scripts/
|
||||
charts/
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0"?>
|
||||
<CommunityApplications>
|
||||
<Profile>TREK is a self-hosted, real-time collaborative travel planner. Plan trips together with interactive maps, budgets, bookings, packing lists, day-by-day itineraries and file management — every change syncs instantly across everyone in your group. Includes OIDC/SSO, TOTP MFA, dark mode, PWA support, multi-language UI and a modular addon system (Vacay, Atlas, Collab, Budget, Packing, Journey). Maintained by mauriceboe — support and bug reports via GitHub Issues.</Profile>
|
||||
<Icon>https://raw.githubusercontent.com/mauriceboe/TREK/main/docs/trek-icon.png</Icon>
|
||||
<WebPage>https://github.com/mauriceboe/TREK</WebPage>
|
||||
<Forum>https://github.com/mauriceboe/TREK/issues</Forum>
|
||||
<DonateLink>https://ko-fi.com/mauriceboe</DonateLink>
|
||||
<DonateText>Support TREK development</DonateText>
|
||||
</CommunityApplications>
|
||||
+1
-1
@@ -39,7 +39,7 @@ See `values.yaml` for more options.
|
||||
|
||||
## Notes
|
||||
- Ingress is off by default. Enable and configure hosts for your domain.
|
||||
- PVCs use the cluster's default StorageClass. Set `persistence.data.storageClassName` and/or `persistence.uploads.storageClassName` to bind a specific class.
|
||||
- PVCs require a default StorageClass or specify one as needed.
|
||||
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
|
||||
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
|
||||
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 3.1.2
|
||||
version: 3.1.0
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "3.1.2"
|
||||
appVersion: "3.1.0"
|
||||
|
||||
@@ -5,16 +5,9 @@ metadata:
|
||||
name: {{ include "trek.fullname" . }}-data
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
{{- with .Values.persistence.data.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
{{- with .Values.persistence.data.storageClassName }}
|
||||
storageClassName: {{ . | quote }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.data.size }}
|
||||
@@ -25,16 +18,9 @@ metadata:
|
||||
name: {{ include "trek.fullname" . }}-uploads
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
{{- with .Values.persistence.uploads.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
{{- with .Values.persistence.uploads.storageClassName }}
|
||||
storageClassName: {{ . | quote }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.uploads.size }}
|
||||
|
||||
@@ -98,13 +98,8 @@ persistence:
|
||||
enabled: true
|
||||
data:
|
||||
size: 1Gi
|
||||
# Leave empty to use the cluster's default StorageClass; set to bind a specific class.
|
||||
storageClassName: ""
|
||||
annotations: {}
|
||||
uploads:
|
||||
size: 1Gi
|
||||
storageClassName: ""
|
||||
annotations: {}
|
||||
|
||||
resources:
|
||||
requests:
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "3.1.1",
|
||||
"version": "3.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -107,7 +107,7 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
|
||||
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
|
||||
const nums = () => screen.getAllByPlaceholderText('0.00') as HTMLInputElement[]
|
||||
const nums = () => screen.getAllByRole('spinbutton') as HTMLInputElement[]
|
||||
await user.type(nums()[0], '100') // total → auto equal-split across the 2 participants
|
||||
await waitFor(() => expect(nums()[1].value).toBe('50'))
|
||||
expect(nums()[2].value).toBe('50')
|
||||
@@ -125,30 +125,6 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
]))
|
||||
})
|
||||
|
||||
it('accepts a comma as the decimal separator in the total amount (#1256)', async () => {
|
||||
let posted: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||
http.post('/api/trips/1/budget', async ({ request }) => {
|
||||
posted = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'AirTags' }), id: 6 } })
|
||||
}),
|
||||
)
|
||||
const { default: userEvent } = await import('@testing-library/user-event')
|
||||
const user = userEvent.setup()
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
|
||||
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'AirTags')
|
||||
await user.type(screen.getAllByPlaceholderText('0.00')[0], '39,99') // comma → normalized to 39.99
|
||||
|
||||
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
|
||||
await user.click(addBtns[addBtns.length - 1]) // footer submit
|
||||
await waitFor(() => expect(posted).toBeTruthy())
|
||||
expect(posted!.total_price).toBe(39.99)
|
||||
})
|
||||
|
||||
it('marks an expense with no payer as Unfinished', async () => {
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Hotel' }), total_price: 90, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
|
||||
server.use(
|
||||
|
||||
@@ -528,16 +528,11 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const isUnfinished = baseTotal(e) > 0 && payers.length === 0
|
||||
return (
|
||||
<div className="bg-surface-card border border-edge exp-row" style={{ display: 'grid', gridTemplateColumns: '46px 1fr auto', gap: 16, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
|
||||
<span style={{ position: 'relative', width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}>
|
||||
<Icon size={21} />
|
||||
{isMobile && isUnfinished && (
|
||||
<span title={t('costs.unfinishedHint')} style={{ position: 'absolute', bottom: -4, right: -4, width: 20, height: 20, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 12, fontWeight: 800, lineHeight: 1, border: '2px solid var(--bg-card)' }}>!</span>
|
||||
)}
|
||||
</span>
|
||||
<span style={{ width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}><Icon size={21} /></span>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 6 }}>
|
||||
<span className="text-content" style={{ fontSize: 15, fontWeight: 600 }}>{e.name}</span>
|
||||
{isUnfinished && !isMobile && (
|
||||
{isUnfinished && (
|
||||
<span title={t('costs.unfinishedHint')} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px 2px 6px', borderRadius: 999, background: 'rgba(217,119,6,0.14)', color: '#d97706', fontSize: 11, fontWeight: 700, flexShrink: 0 }}>
|
||||
<span style={{ width: 14, height: 14, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 10, fontWeight: 800 }}>!</span>
|
||||
{t('costs.unfinished')}
|
||||
@@ -637,16 +632,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
|
||||
function CategoryBreakdown() {
|
||||
const tot: Record<string, number> = {}
|
||||
for (const e of budgetItems) { const k = catMeta(e.category).key; tot[k] = (tot[k] || 0) + baseTotal(e) }
|
||||
let grand = 0
|
||||
for (const e of budgetItems) { const k = catMeta(e.category).key; tot[k] = (tot[k] || 0) + baseTotal(e); grand += baseTotal(e) }
|
||||
const rows = COST_CATEGORY_LIST.filter(c => (tot[c.key] || 0) > 0).sort((a, b) => (tot[b.key] || 0) - (tot[a.key] || 0))
|
||||
if (rows.length === 0) return <div className="text-content-faint" style={{ fontSize: 12.5 }}>{t('costs.noCategories')}</div>
|
||||
// Bars are scaled relative to the most expensive category (the top row fills the
|
||||
// bar), not to the trip grand total — makes the relative ranking readable.
|
||||
const maxCat = Math.max(0, ...rows.map(c => tot[c.key] || 0))
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{rows.map(c => {
|
||||
const v = tot[c.key]; const pct = maxCat ? v / maxCat * 100 : 0
|
||||
const v = tot[c.key]; const pct = grand ? v / grand * 100 : 0
|
||||
return (
|
||||
<div key={c.key} style={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 10, alignItems: 'center' }}>
|
||||
<span style={{ width: 10, height: 10, borderRadius: 3, background: c.color }} />
|
||||
@@ -761,8 +754,8 @@ function SettlementModal({ tripId, people, me, editing, onClose, onSaved }: {
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t('costs.amount')}</label>
|
||||
<input type="text" inputMode="decimal" placeholder="0.00" value={amount}
|
||||
onChange={e => setAmount(e.target.value.replace(',', '.'))} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 14, outline: 'none', fontWeight: 600 }} />
|
||||
<input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={amount}
|
||||
onChange={e => setAmount(e.target.value)} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 14, outline: 'none', fontWeight: 600 }} />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -840,12 +833,10 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
}
|
||||
|
||||
const onTotalChange = (v: string) => {
|
||||
v = v.replace(',', '.')
|
||||
setTotal(v)
|
||||
setPaid(prev => rebalance(prev, dirty, participants, parseFloat(v) || 0))
|
||||
}
|
||||
const onPaidChange = (id: number, v: string) => {
|
||||
v = v.replace(',', '.')
|
||||
const nextDirty = new Set(dirty); nextDirty.add(id)
|
||||
setDirty(nextDirty)
|
||||
setPaid(prev => rebalance({ ...prev, [id]: v }, nextDirty, participants, totalNum))
|
||||
@@ -905,7 +896,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
<label className={labelCls}>{t('costs.totalAmount')}</label>
|
||||
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px' }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 15 }}>{sym(currency)}</span>
|
||||
<input type="text" inputMode="decimal" placeholder="0.00" value={total}
|
||||
<input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={total}
|
||||
onChange={e => onTotalChange(e.target.value)}
|
||||
className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 15, fontWeight: 600, paddingLeft: 6, width: '100%' }} />
|
||||
</div>
|
||||
@@ -965,7 +956,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
{on ? (
|
||||
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 13 }}>{sym(currency)}</span>
|
||||
<input type="text" inputMode="decimal" placeholder="0.00" value={paid[p.id] || ''}
|
||||
<input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={paid[p.id] || ''}
|
||||
onChange={e => onPaidChange(p.id, e.target.value)}
|
||||
className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
|
||||
</div>
|
||||
|
||||
@@ -323,28 +323,6 @@ describe('downloadTripPDF', () => {
|
||||
expect(photoCalled).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-COMP-TRIPPDF-019b: fetches photos for OSM places via osm_id recovered from the places pool (#1130)', async () => {
|
||||
let fetchedId: string | null = null
|
||||
server.use(
|
||||
http.get('/api/maps/place-photo/:placeId', ({ params }) => {
|
||||
fetchedId = params.placeId as string
|
||||
return HttpResponse.json({ photoUrl: 'https://example.com/osm.jpg' })
|
||||
}),
|
||||
)
|
||||
// The assignment projection drops osm_id; the full place in `places` carries it.
|
||||
const osmPlace = { ...placeWithDetails, id: 101, image_url: null, google_place_id: null, osm_id: 'node/240109189', lat: 41.89, lng: 12.49 }
|
||||
const args = {
|
||||
...richArgs,
|
||||
places: [osmPlace],
|
||||
assignments: {
|
||||
'10': [{ ...assignmentForDay, id: 201, place_id: 101, place: { ...placeWithDetails, id: 101, image_url: null, google_place_id: null } }],
|
||||
} as any,
|
||||
}
|
||||
await downloadTripPDF(args)
|
||||
// osm_id is used as the photo key (not the coords fallback), proving the pool lookup works.
|
||||
expect(fetchedId).toBe('node/240109189')
|
||||
})
|
||||
|
||||
it('FE-COMP-TRIPPDF-020: renders empty day message when no items assigned', async () => {
|
||||
const args = {
|
||||
...minimalArgs,
|
||||
|
||||
@@ -97,29 +97,21 @@ function dayCost(assignments, dayId, locale) {
|
||||
return total > 0 ? `${total.toLocaleString(locale)} EUR` : null
|
||||
}
|
||||
|
||||
// Pre-fetch place photos for all assigned places.
|
||||
// Assignment places are a server-side projection that drops osm_id, so we recover
|
||||
// the full place from the trip's places pool and key the photo off the same id the
|
||||
// app UI uses (google_place_id || osm_id || coords) — otherwise OSM/coords-only
|
||||
// places fell back to category icons in the PDF even though they show photos in-app.
|
||||
async function fetchPlacePhotos(assignments: AssignmentsMap, places: Place[]) {
|
||||
// Pre-fetch Google Place photos for all assigned places
|
||||
async function fetchPlacePhotos(assignments: AssignmentsMap) {
|
||||
const photoMap = {} // placeId → photoUrl
|
||||
// The assignment projection drops osm_id, so recover it from the full places pool.
|
||||
const osmById = new Map((places || []).map(p => [p.id, p.osm_id]))
|
||||
const allPlaces = Object.values(assignments).flatMap(a => a.map(x => x.place)).filter(Boolean)
|
||||
const unique = [...new Map(allPlaces.map(p => [p.id, p])).values()]
|
||||
|
||||
const toFetch = unique
|
||||
.map(p => ({ p, osm_id: osmById.get(p.id) }))
|
||||
.filter(({ p, osm_id }) => !p.image_url && (p.google_place_id || osm_id || (p.lat != null && p.lng != null)))
|
||||
// Assignment places are a server-side projection that omits osm_id, so photo
|
||||
// pre-fetch keys off the google_place_id that the projection does carry.
|
||||
const toFetch = unique.filter(p => !p.image_url && p.google_place_id)
|
||||
|
||||
await Promise.allSettled(
|
||||
toFetch.map(async ({ p, osm_id }) => {
|
||||
// Same key the app UI uses: google_place_id || osm_id || coords.
|
||||
const photoId = p.google_place_id || osm_id || `coords:${p.lat}:${p.lng}`
|
||||
toFetch.map(async (place) => {
|
||||
try {
|
||||
const data = await mapsApi.placePhoto(photoId, p.lat, p.lng, p.name)
|
||||
if (data.photoUrl) photoMap[p.id] = data.photoUrl
|
||||
const data = await mapsApi.placePhoto(place.google_place_id, place.lat, place.lng, place.name)
|
||||
if (data.photoUrl) photoMap[place.id] = data.photoUrl
|
||||
} catch {}
|
||||
})
|
||||
)
|
||||
@@ -149,8 +141,8 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
//retrieve accommodations for the trip to display on the day sections and prefetch their photos if needed
|
||||
const accommodations = await accommodationsApi.list(trip.id);
|
||||
|
||||
// Pre-fetch place photos (Google, OSM and coords-only places)
|
||||
const photoMap = await fetchPlacePhotos(assignments, places)
|
||||
// Pre-fetch place photos from Google
|
||||
const photoMap = await fetchPlacePhotos(assignments)
|
||||
|
||||
const totalAssigned = new Set(
|
||||
Object.values(assignments || {}).flatMap(a => a.map(x => x.place?.id)).filter(Boolean)
|
||||
|
||||
@@ -5,7 +5,7 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
|
||||
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo } from 'react'
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon } from 'lucide-react'
|
||||
import { assignmentsApi, reservationsApi } from '../../api/client'
|
||||
import { calculateRoute, calculateRouteWithLegs, optimizeRoute, generateGoogleMapsUrl } from '../Map/RouteCalculator'
|
||||
import { calculateRoute, calculateRouteWithLegs, optimizeRoute } from '../Map/RouteCalculator'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
||||
@@ -2168,28 +2168,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
<RouteIcon size={12} strokeWidth={2} />
|
||||
{t('dayplan.route')}
|
||||
</button>
|
||||
{/* Open the day's stops as a route in Google Maps (planned order). #1255 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const url = generateGoogleMapsUrl(getDayAssignments(day.id).map(a => a.place).filter(p => p?.lat != null && p?.lng != null) as { lat: number; lng: number }[])
|
||||
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}}
|
||||
aria-label={t('planner.openGoogleMaps')}
|
||||
title={t('planner.openGoogleMaps')}
|
||||
className="bg-transparent text-content-secondary"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-faint)',
|
||||
cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 48 48" fill="currentColor" aria-hidden="true">
|
||||
<path d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z" />
|
||||
<path d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z" />
|
||||
<path d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z" />
|
||||
<path d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button onClick={() => handleOptimize(day.id)} className="bg-surface-hover text-content-secondary" style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -102,9 +102,7 @@ export function ToastContainer() {
|
||||
`}</style>
|
||||
<div style={{
|
||||
position: 'fixed', bottom: 24, left: '50%', transform: 'translateX(-50%)',
|
||||
// Above modal overlays (which sit around z-index 10000 with a backdrop-filter
|
||||
// blur) so error toasts paint on top and stay legible instead of blurred behind.
|
||||
zIndex: 100000, display: 'flex', flexDirection: 'column-reverse', gap: 8,
|
||||
zIndex: 9999, display: 'flex', flexDirection: 'column-reverse', gap: 8,
|
||||
pointerEvents: 'none', maxWidth: 420, width: '100%', padding: '0 16px',
|
||||
}}>
|
||||
{toasts.map(toast => (
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -229,24 +229,12 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
|
||||
|
||||
<div style={{ padding: '20px 24px' }}>
|
||||
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
|
||||
{(updateInfo?.is_docker === false ? t('admin.update.nonDockerText') : t('admin.update.dockerText')).replace('{version}', `v${updateInfo?.latest ?? ''}`)}
|
||||
{t('admin.update.dockerText').replace('{version}', `v${updateInfo?.latest ?? ''}`)}
|
||||
</p>
|
||||
|
||||
{updateInfo?.is_docker === false ? (
|
||||
<a
|
||||
href="https://github.com/mauriceboe/TREK/wiki/Updating"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ marginTop: 14, padding: '12px 14px', borderRadius: 10, fontSize: 13, lineHeight: 1.5, display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none' }}
|
||||
className="bg-gray-50 dark:bg-gray-900 text-gray-700 dark:text-gray-200 border border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="font-semibold underline">{t('admin.update.wikiLink')}</span>
|
||||
</a>
|
||||
) : (
|
||||
<div style={{ marginTop: 14, padding: '12px 14px', borderRadius: 10, fontSize: 12, lineHeight: 1.8, fontFamily: 'monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
|
||||
className="bg-gray-900 dark:bg-gray-950 text-gray-100 border border-gray-700"
|
||||
>
|
||||
<div style={{ marginTop: 14, padding: '12px 14px', borderRadius: 10, fontSize: 12, lineHeight: 1.8, fontFamily: 'monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
|
||||
className="bg-gray-900 dark:bg-gray-950 text-gray-100 border border-gray-700"
|
||||
>
|
||||
{`docker pull mauriceboe/trek:latest
|
||||
docker stop trek && docker rm trek
|
||||
docker run -d --name trek \\
|
||||
@@ -255,8 +243,7 @@ docker run -d --name trek \\
|
||||
-v /opt/trek/uploads:/app/uploads \\
|
||||
--restart unless-stopped \\
|
||||
mauriceboe/trek:latest`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
|
||||
className="bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-800"
|
||||
|
||||
@@ -134,12 +134,9 @@ export function useAtlas() {
|
||||
}, [])
|
||||
|
||||
// Load country-border GeoJSON from our API (geoBoundaries, served server-side —
|
||||
// no third-party fetch from the browser). Even gzipped the payload is a few MB, so
|
||||
// it gets a longer timeout than the global 8s default to survive slow links and
|
||||
// reverse-proxy / Cloudflare-Tunnel setups instead of aborting and leaving the map
|
||||
// with no countries (#1254).
|
||||
// no third-party fetch from the browser).
|
||||
useEffect(() => {
|
||||
apiClient.get('/addons/atlas/countries/geo', { timeout: 30000 })
|
||||
apiClient.get('/addons/atlas/countries/geo')
|
||||
.then(res => {
|
||||
const geo = res.data
|
||||
// Dynamically build A2→A3 mapping from GeoJSON
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -218,7 +218,7 @@
|
||||
opacity: .88; margin-bottom: 16px; font-weight: 500;
|
||||
}
|
||||
.trek-dash .hero-eyebrow::before { content: ""; width: 28px; height: 1px; background: oklch(1 0 0 / .6); }
|
||||
.trek-dash .hero-title { font-size: 104px; font-weight: 600; line-height: 0.9; letter-spacing: -0.045em; margin: 0; text-shadow: 0 1px 12px oklch(0 0 0 / .32), 0 1px 3px oklch(0 0 0 / .4); }
|
||||
.trek-dash .hero-title { font-size: 104px; font-weight: 600; line-height: 0.9; letter-spacing: -0.045em; margin: 0; }
|
||||
|
||||
/* ----------------- boarding pass ----------------- */
|
||||
.trek-dash .hero-pass {
|
||||
@@ -422,7 +422,7 @@
|
||||
.trek-dash .trip-action-btn:hover { background: oklch(1 0 0 / .3); }
|
||||
.trek-dash .trip-action-btn svg { width: 16px; height: 16px; }
|
||||
.trek-dash .trip-cover-content { position: absolute; left: 18px; right: 18px; bottom: 16px; z-index: 1; color: #fff; }
|
||||
.trek-dash .trip-name { font-size: 26px; font-weight: 600; letter-spacing: -0.025em; line-height: 1.05; margin: 0; text-shadow: 0 1px 7px oklch(0 0 0 / .3), 0 1px 2px oklch(0 0 0 / .38); }
|
||||
.trek-dash .trip-name { font-size: 26px; font-weight: 600; letter-spacing: -0.025em; line-height: 1.05; margin: 0; }
|
||||
.trek-dash .trip-where { margin-top: 4px; font-size: 13px; opacity: .85; display: flex; align-items: center; gap: 6px; }
|
||||
.trek-dash .trip-where svg { width: 12px; height: 12px; opacity: .8; }
|
||||
.trek-dash .trip-body { padding: 18px 20px 20px; }
|
||||
|
||||
Generated
+1415
-1221
File diff suppressed because it is too large
Load Diff
+2
-3
@@ -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
-6
@@ -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",
|
||||
@@ -30,7 +30,6 @@
|
||||
"archiver": "^6.0.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"compression": "^1.8.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.1",
|
||||
@@ -39,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",
|
||||
@@ -60,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"
|
||||
@@ -73,7 +73,6 @@
|
||||
"@types/archiver": "^7.0.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/compression": "^1.8.0",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^4.17.25",
|
||||
@@ -81,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
@@ -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 })`. */
|
||||
|
||||
@@ -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) }] };
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -230,7 +227,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
||||
tripId: z.number().int().positive(),
|
||||
dayId: z.number().int().positive(),
|
||||
text: z.string().min(1).max(500),
|
||||
time: z.string().max(250).optional().describe('Time label (e.g. "09:00" or "Morning")'),
|
||||
time: z.string().max(150).optional().describe('Time label (e.g. "09:00" or "Morning")'),
|
||||
icon: z.string().optional().describe('Emoji icon for the note'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
@@ -255,7 +252,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
||||
dayId: z.number().int().positive(),
|
||||
noteId: z.number().int().positive(),
|
||||
text: z.string().min(1).max(500).optional(),
|
||||
time: z.string().max(250).nullable().optional().describe('Time label (e.g. "09:00" or "Morning"), or null to clear'),
|
||||
time: z.string().max(150).nullable().optional().describe('Time label (e.g. "09:00" or "Morning"), or null to clear'),
|
||||
icon: z.string().optional().describe('Emoji icon for the note'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import compression from 'compression';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import cookieParser from 'cookie-parser';
|
||||
@@ -29,21 +28,6 @@ export function applyGlobalMiddleware(
|
||||
app.set('trust proxy', Number.parseInt(process.env.TRUST_PROXY) || 1);
|
||||
}
|
||||
|
||||
// Compress responses (gzip via Accept-Encoding). The Atlas admin-0 country
|
||||
// GeoJSON is ~30 MB uncompressed, which stalls/aborts (~8s → net::ERR_FAILED)
|
||||
// behind reverse proxies and Cloudflare Tunnel (#1254); gzip brings it to ~4 MB.
|
||||
// SSE responses (the /mcp StreamableHTTP transport) must NOT be buffered, so
|
||||
// they are excluded explicitly.
|
||||
app.use(
|
||||
compression({
|
||||
filter: (req, res) => {
|
||||
const type = res.getHeader('Content-Type');
|
||||
if (typeof type === 'string' && type.includes('text/event-stream')) return false;
|
||||
return compression.filter(req, res);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const allowedOrigins = process.env.ALLOWED_ORIGINS
|
||||
? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim()).filter(Boolean)
|
||||
: null;
|
||||
@@ -119,9 +103,7 @@ export function applyGlobalMiddleware(
|
||||
workerSrc: ["'self'", "blob:"],
|
||||
childSrc: ["'self'", "blob:"],
|
||||
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
|
||||
// 'self' so same-origin file previews can embed PDFs via <object>/<embed>
|
||||
// (Firefox/Chrome enforce object-src; 'none' broke inline PDF previews there).
|
||||
objectSrc: ["'self'"],
|
||||
objectSrc: ["'none'"],
|
||||
frameSrc: ["'none'"],
|
||||
frameAncestors: ["'self'"],
|
||||
// Restrict <form> submission targets (form-action has no default-src
|
||||
|
||||
@@ -17,10 +17,9 @@ import { CurrentUser } from '../auth/current-user.decorator';
|
||||
|
||||
type DayNoteBody = { text?: string; time?: string; icon?: string; sort_order?: number };
|
||||
|
||||
// Runs BEFORE the trip-access check, so an over-long field 400s first. The `time`
|
||||
// cap matches the shared dayNote schema (max 250) and the note dialog's counter;
|
||||
// it was 150 here, which rejected valid 151–250 char notes with a confusing error.
|
||||
const MAX_LENGTHS: Record<string, number> = { text: 500, time: 250 };
|
||||
// Mirrors the legacy validateStringLengths({ text: 500, time: 150 }) middleware,
|
||||
// which runs BEFORE the trip-access check — so an over-long field 400s first.
|
||||
const MAX_LENGTHS: Record<string, number> = { text: 500, time: 150 };
|
||||
|
||||
function validateLengths(body: Record<string, unknown>): void {
|
||||
for (const [field, max] of Object.entries(MAX_LENGTHS)) {
|
||||
|
||||
@@ -141,16 +141,6 @@ export function updateUser(id: string, data: { username?: string; email?: string
|
||||
}
|
||||
const passwordHash = password ? bcrypt.hashSync(password, BCRYPT_COST) : null;
|
||||
|
||||
// Don't let the admin UI demote the last remaining admin — that would leave the
|
||||
// instance with no one able to manage it (and on OIDC-only setups, no recovery). #1274
|
||||
if (role && role !== 'admin') {
|
||||
const current = db.prepare('SELECT role FROM users WHERE id = ?').get(id) as { role?: string } | undefined;
|
||||
if (current?.role === 'admin') {
|
||||
const adminCount = (db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'").get() as { count: number }).count;
|
||||
if (adminCount <= 1) return { error: 'Cannot remove the last admin', status: 400 };
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
UPDATE users SET
|
||||
username = COALESCE(?, username),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -935,16 +935,13 @@ export function getTravelStats(userId: number) {
|
||||
WHERE t.user_id = ? OR tm.user_id = ?
|
||||
`).all(userId, userId) as { address: string | null; lat: number | null; lng: number | null }[];
|
||||
|
||||
// Archived trips still count here, matching the places, countries and flight
|
||||
// distance widgets (which never filtered on is_archived) so the dashboard stats
|
||||
// stay consistent — archiving a trip no longer zeroes out trips/days.
|
||||
const tripStats = db.prepare(`
|
||||
SELECT COUNT(DISTINCT t.id) as trips,
|
||||
COUNT(DISTINCT d.id) as days
|
||||
FROM trips t
|
||||
LEFT JOIN days d ON d.trip_id = t.id
|
||||
LEFT JOIN trip_members tm ON t.id = tm.trip_id
|
||||
WHERE (t.user_id = ? OR tm.user_id = ?)
|
||||
WHERE (t.user_id = ? OR tm.user_id = ?) AND t.is_archived = 0
|
||||
`).get(userId, userId) as { trips: number; days: number } | undefined;
|
||||
|
||||
const cities = new Set<string>();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -385,20 +385,8 @@ export function findOrCreateUser(
|
||||
if (process.env.OIDC_ADMIN_VALUE) {
|
||||
const newRole = resolveOidcRole(userInfo, false);
|
||||
if (user.role !== newRole) {
|
||||
// Never let the claim-based downgrade strip the last admin. The bootstrap
|
||||
// admin (first SSO user) usually doesn't carry the admin claim, so a forced
|
||||
// re-login — e.g. after a JWT-secret rotation — would otherwise demote it and
|
||||
// lock an OIDC-only instance out for good. #1274
|
||||
const demotingLastAdmin =
|
||||
user.role === 'admin' &&
|
||||
newRole !== 'admin' &&
|
||||
(db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'").get() as { count: number }).count <= 1;
|
||||
if (demotingLastAdmin) {
|
||||
console.warn(`[OIDC] Kept admin role for user ${user.id}: their OIDC claims map to '${newRole}', but they are the only admin — demoting would lock the instance out.`);
|
||||
} else {
|
||||
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(newRole, user.id);
|
||||
user = { ...user, role: newRole } as User;
|
||||
}
|
||||
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(newRole, user.id);
|
||||
user = { ...user, role: newRole } as User;
|
||||
}
|
||||
}
|
||||
return { user };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -122,17 +122,4 @@ describe('BOOTSTRAP (F6) — unified NestJS app serves the whole surface', () =>
|
||||
else process.env.NODE_ENV = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it('BOOT-008 — large responses are gzip-compressed (Atlas country GeoJSON, #1254)', async () => {
|
||||
// The admin-0 country GeoJSON is multi-MB; without compression it stalls
|
||||
// behind reverse proxies / Cloudflare Tunnel. Proves applyGlobalMiddleware
|
||||
// gzips it on the wire.
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(instance)
|
||||
.get('/api/addons/atlas/countries/geo')
|
||||
.set('Accept-Encoding', 'gzip')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-encoding']).toBe('gzip');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -127,8 +127,8 @@ describe('DayNotesController (parity with the legacy /api/.../days/:dayId/notes
|
||||
});
|
||||
|
||||
it('400 on an over-long time', () => {
|
||||
expect(thrown(() => new DayNotesController(notesSvc()).create(user, '5', '3', { text: 'ok', time: 'y'.repeat(251) }))).toEqual({
|
||||
status: 400, body: { error: 'time must be 250 characters or less' },
|
||||
expect(thrown(() => new DayNotesController(notesSvc()).create(user, '5', '3', { text: 'ok', time: 'y'.repeat(151) }))).toEqual({
|
||||
status: 400, body: { error: 'time must be 150 characters or less' },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -284,9 +284,6 @@ const admin: TranslationStrings = {
|
||||
'admin.update.backupLink': 'الذهاب إلى النسخ الاحتياطي',
|
||||
'admin.update.howTo': 'كيفية التحديث',
|
||||
'admin.update.dockerText': 'يعمل TREK الخاص بك في Docker. للتحديث إلى {version}، نفّذ الأوامر التالية على الخادم:',
|
||||
'admin.update.nonDockerText':
|
||||
'لا يعمل TREK هذا في Docker. للتحديث إلى {version}، أعد تشغيل طريقة التثبيت أو التحديث التي استخدمتها — على سبيل المثال، في Proxmox Community Scripts نفّذ التحديث من وحدة تحكم LXC:',
|
||||
'admin.update.wikiLink': 'فتح دليل التحديث',
|
||||
'admin.update.reloadHint': 'يرجى إعادة تحميل الصفحة بعد بضع ثوانٍ.',
|
||||
'admin.tabs.permissions': 'الصلاحيات',
|
||||
'admin.notifications.webhook': 'Webhook', // en-fallback
|
||||
|
||||
@@ -241,9 +241,6 @@ const admin: TranslationStrings = {
|
||||
'admin.update.backupLink': 'Ir para Backup',
|
||||
'admin.update.howTo': 'Como atualizar',
|
||||
'admin.update.dockerText': 'Sua instância TREK roda no Docker. Para atualizar para {version}, execute no servidor:',
|
||||
'admin.update.nonDockerText':
|
||||
'Esta instância do TREK não está rodando no Docker. Para atualizar para {version}, execute novamente o método de instalação ou atualização que você usou — por exemplo, no Proxmox Community Scripts, execute a atualização a partir do console do LXC:',
|
||||
'admin.update.wikiLink': 'Abrir o guia de atualização',
|
||||
'admin.update.reloadHint': 'Recarregue a página em alguns segundos.',
|
||||
'admin.tabs.permissions': 'Permissões',
|
||||
'admin.tabs.mcpTokens': 'Acesso MCP',
|
||||
|
||||
@@ -268,9 +268,6 @@ const admin: TranslationStrings = {
|
||||
'admin.update.howTo': 'Jak aktualizovat',
|
||||
'admin.update.dockerText':
|
||||
'Váš TREK běží v Dockeru. Pro aktualizaci na verzi {version} spusťte na svém serveru tyto příkazy:',
|
||||
'admin.update.nonDockerText':
|
||||
'Tato instance TREK neběží v Dockeru. Pro aktualizaci na verzi {version} znovu spusťte instalační nebo aktualizační metodu, kterou jste použili — například u Proxmox Community Scripts spusťte aktualizaci z konzole LXC:',
|
||||
'admin.update.wikiLink': 'Otevřít průvodce aktualizací',
|
||||
'admin.update.reloadHint': 'Prosím obnovte stránku za několik sekund.',
|
||||
'admin.tabs.permissions': 'Oprávnění',
|
||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||
|
||||
@@ -272,9 +272,6 @@ const admin: TranslationStrings = {
|
||||
'admin.update.howTo': 'Update-Anleitung',
|
||||
'admin.update.dockerText':
|
||||
'Deine TREK-Instanz läuft in Docker. Um auf {version} zu aktualisieren, führe folgende Befehle auf deinem Server aus:',
|
||||
'admin.update.nonDockerText':
|
||||
'Diese TREK-Instanz läuft nicht in Docker. Um auf {version} zu aktualisieren, führe die Installations- oder Update-Methode erneut aus, die du verwendet hast — bei Proxmox Community Scripts startest du das Update zum Beispiel über die LXC-Konsole:',
|
||||
'admin.update.wikiLink': 'Update-Anleitung öffnen',
|
||||
'admin.update.reloadHint': 'Bitte lade die Seite in wenigen Sekunden neu.',
|
||||
'admin.tabs.permissions': 'Berechtigungen',
|
||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||
|
||||
@@ -322,9 +322,6 @@ const admin: TranslationStrings = {
|
||||
'admin.update.howTo': 'How to Update',
|
||||
'admin.update.dockerText':
|
||||
'Your TREK instance runs in Docker. To update to {version}, run the following commands on your server:',
|
||||
'admin.update.nonDockerText':
|
||||
'This TREK instance is not running in Docker. To update to {version}, re-run the install or update method you used — for example, on Proxmox Community Scripts run the update from the LXC console:',
|
||||
'admin.update.wikiLink': 'Open the update guide',
|
||||
'admin.update.reloadHint': 'Please reload the page in a few seconds.',
|
||||
'admin.tabs.permissions': 'Permissions',
|
||||
'admin.addons.catalog.journey.name': 'Journey',
|
||||
|
||||
@@ -256,9 +256,6 @@ const admin: TranslationStrings = {
|
||||
'admin.update.howTo': 'Cómo actualizar',
|
||||
'admin.update.dockerText':
|
||||
'Tu instancia de TREK se ejecuta en Docker. Para actualizar a {version}, ejecuta los siguientes comandos en tu servidor:',
|
||||
'admin.update.nonDockerText':
|
||||
'Esta instancia de TREK no se ejecuta en Docker. Para actualizar a {version}, vuelve a ejecutar el método de instalación o actualización que utilizaste; por ejemplo, en Proxmox Community Scripts ejecuta la actualización desde la consola LXC:',
|
||||
'admin.update.wikiLink': 'Abrir la guía de actualización',
|
||||
'admin.update.reloadHint': 'Recarga la página en unos segundos.',
|
||||
'admin.addons.catalog.memories.name': 'Fotos (Immich)',
|
||||
'admin.addons.catalog.memories.description': 'Comparte fotos de viaje a través de tu instancia de Immich',
|
||||
|
||||
@@ -274,9 +274,6 @@ const admin: TranslationStrings = {
|
||||
'admin.update.howTo': 'Comment mettre à jour',
|
||||
'admin.update.dockerText':
|
||||
'Votre instance TREK fonctionne dans Docker. Pour mettre à jour vers {version}, exécutez les commandes suivantes sur votre serveur :',
|
||||
'admin.update.nonDockerText':
|
||||
"Cette instance TREK ne fonctionne pas dans Docker. Pour mettre à jour vers {version}, relancez la méthode d'installation ou de mise à jour que vous avez utilisée — par exemple, sur Proxmox Community Scripts, lancez la mise à jour depuis la console LXC :",
|
||||
'admin.update.wikiLink': 'Ouvrir le guide de mise à jour',
|
||||
'admin.update.reloadHint': 'Veuillez recharger la page dans quelques secondes.',
|
||||
'admin.tabs.permissions': 'Permissions',
|
||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||
|
||||
@@ -323,9 +323,6 @@ const admin: TranslationStrings = {
|
||||
'admin.update.howTo': 'Πώς να Ενημερώσετε',
|
||||
'admin.update.dockerText':
|
||||
'Η εγκατάστασή σας TREK εκτελείται σε Docker. Για να ενημερωθείτε στο {version}, εκτελέστε τις ακόλουθες εντολές στον server σας:',
|
||||
'admin.update.nonDockerText':
|
||||
'Αυτή η εγκατάσταση TREK δεν εκτελείται σε Docker. Για να ενημερωθείτε στο {version}, εκτελέστε ξανά τη μέθοδο εγκατάστασης ή ενημέρωσης που χρησιμοποιήσατε — για παράδειγμα, στα Proxmox Community Scripts εκτελέστε την ενημέρωση από την κονσόλα LXC:',
|
||||
'admin.update.wikiLink': 'Άνοιγμα του οδηγού ενημέρωσης',
|
||||
'admin.update.reloadHint': 'Παρακαλώ ανανεώστε τη σελίδα σε λίγα δευτερόλεπτα.',
|
||||
'admin.tabs.permissions': 'Δικαιώματα',
|
||||
'admin.addons.catalog.journey.name': 'Ταξίδι',
|
||||
|
||||
@@ -273,9 +273,6 @@ const admin: TranslationStrings = {
|
||||
'admin.update.howTo': 'Frissítési útmutató',
|
||||
'admin.update.dockerText':
|
||||
'A TREK példányod Dockerben fut. A {version} verzióra frissítéshez futtasd a következő parancsokat a szervereden:',
|
||||
'admin.update.nonDockerText':
|
||||
'Ez a TREK példány nem Dockerben fut. A {version} verzióra frissítéshez futtasd újra a telepítési vagy frissítési módszert, amelyet használtál — például Proxmox Community Scripts esetén futtasd a frissítést az LXC konzolból:',
|
||||
'admin.update.wikiLink': 'Frissítési útmutató megnyitása',
|
||||
'admin.update.reloadHint': 'Kérjük, töltsd újra az oldalt néhány másodperc múlva.',
|
||||
'admin.tabs.permissions': 'Jogosultságok',
|
||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||
|
||||
@@ -313,9 +313,6 @@ const admin: TranslationStrings = {
|
||||
'admin.update.howTo': 'Cara Memperbarui',
|
||||
'admin.update.dockerText':
|
||||
'Instans TREK kamu berjalan di Docker. Untuk memperbarui ke {version}, jalankan perintah berikut di servermu:',
|
||||
'admin.update.nonDockerText':
|
||||
'Instans TREK ini tidak berjalan di Docker. Untuk memperbarui ke {version}, jalankan ulang metode instalasi atau pembaruan yang kamu gunakan — misalnya, pada Proxmox Community Scripts jalankan pembaruan dari konsol LXC:',
|
||||
'admin.update.wikiLink': 'Buka panduan pembaruan',
|
||||
'admin.update.reloadHint': 'Muat ulang halaman dalam beberapa detik.',
|
||||
'admin.tabs.permissions': 'Izin',
|
||||
'admin.addons.catalog.journey.name': 'Journey',
|
||||
|
||||
@@ -272,9 +272,6 @@ const admin: TranslationStrings = {
|
||||
'admin.update.howTo': 'Come aggiornare',
|
||||
'admin.update.dockerText':
|
||||
'La tua istanza TREK è in esecuzione in Docker. Per aggiornare alla versione {version}, esegui i seguenti comandi sul tuo server:',
|
||||
'admin.update.nonDockerText':
|
||||
"Questa istanza TREK non è in esecuzione in Docker. Per aggiornare alla versione {version}, riesegui il metodo di installazione o aggiornamento che hai usato — ad esempio, su Proxmox Community Scripts esegui l'aggiornamento dalla console LXC:",
|
||||
'admin.update.wikiLink': "Apri la guida all'aggiornamento",
|
||||
'admin.update.reloadHint': 'Ricarica la pagina tra qualche secondo.',
|
||||
'admin.tabs.permissions': 'Permessi',
|
||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||
|
||||
@@ -301,9 +301,6 @@ const admin: TranslationStrings = {
|
||||
'admin.update.howTo': '更新方法',
|
||||
'admin.update.dockerText':
|
||||
'TREKはDockerで実行されています。{version} に更新するには、サーバーで次のコマンドを実行してください:',
|
||||
'admin.update.nonDockerText':
|
||||
'このTREKインスタンスはDockerで実行されていません。{version} に更新するには、使用したインストールまたは更新方法をもう一度実行してください。たとえばProxmox Community Scriptsの場合は、LXCコンソールから更新を実行します:',
|
||||
'admin.update.wikiLink': '更新ガイドを開く',
|
||||
'admin.update.reloadHint': '数秒後にページを再読み込みしてください。',
|
||||
'admin.tabs.permissions': '権限',
|
||||
'admin.addons.catalog.journey.name': '日記',
|
||||
|
||||
@@ -305,9 +305,6 @@ const admin: TranslationStrings = {
|
||||
'admin.update.howTo': '업데이트 방법',
|
||||
'admin.update.dockerText':
|
||||
'TREK 인스턴스가 Docker에서 실행 중입니다. {version}으로 업데이트하려면 서버에서 다음 명령을 실행하세요:',
|
||||
'admin.update.nonDockerText':
|
||||
'이 TREK 인스턴스는 Docker에서 실행되고 있지 않습니다. {version}으로 업데이트하려면 사용했던 설치 또는 업데이트 방법을 다시 실행하세요 — 예를 들어 Proxmox Community Scripts에서는 LXC 콘솔에서 업데이트를 실행하세요:',
|
||||
'admin.update.wikiLink': '업데이트 가이드 열기',
|
||||
'admin.update.reloadHint': '잠시 후 페이지를 새로 고침하세요.',
|
||||
'admin.tabs.permissions': '권한',
|
||||
'admin.addons.catalog.journey.name': 'Journey',
|
||||
|
||||
@@ -272,9 +272,6 @@ const admin: TranslationStrings = {
|
||||
'admin.update.howTo': 'Hoe bij te werken',
|
||||
'admin.update.dockerText':
|
||||
"Je TREK-instantie draait in Docker. Om bij te werken naar {version}, voer de volgende commando's uit op je server:",
|
||||
'admin.update.nonDockerText':
|
||||
'Deze TREK-instantie draait niet in Docker. Om bij te werken naar {version}, voer de installatie- of updatemethode die je hebt gebruikt opnieuw uit — bij Proxmox Community Scripts voer je de update bijvoorbeeld uit vanuit de LXC-console:',
|
||||
'admin.update.wikiLink': 'Open de updatehandleiding',
|
||||
'admin.update.reloadHint': 'Herlaad de pagina over een paar seconden.',
|
||||
'admin.tabs.permissions': 'Rechten',
|
||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||
|
||||
@@ -265,9 +265,6 @@ const admin: TranslationStrings = {
|
||||
'admin.update.howTo': 'Jak zaktualizować',
|
||||
'admin.update.dockerText':
|
||||
'Twoja instancja TREK działa w Dockerze. Aby zaktualizować do {version}, uruchom następujące polecenia na swoim serwerze:',
|
||||
'admin.update.nonDockerText':
|
||||
'Ta instancja TREK nie działa w Dockerze. Aby zaktualizować do {version}, uruchom ponownie metodę instalacji lub aktualizacji, której użyłeś — na przykład w Proxmox Community Scripts uruchom aktualizację z konsoli LXC:',
|
||||
'admin.update.wikiLink': 'Otwórz przewodnik aktualizacji',
|
||||
'admin.update.reloadHint': 'Proszę odświeżyć stronę za kilka sekund.',
|
||||
'admin.notifications.title': 'Powiadomienia',
|
||||
'admin.notifications.hint': 'Wybierz jeden kanał powiadomień.',
|
||||
|
||||
@@ -271,9 +271,6 @@ const admin: TranslationStrings = {
|
||||
'admin.update.howTo': 'Как обновить',
|
||||
'admin.update.dockerText':
|
||||
'Ваш экземпляр TREK работает в Docker. Для обновления до {version} выполните следующие команды на сервере:',
|
||||
'admin.update.nonDockerText':
|
||||
'Этот экземпляр TREK работает не в Docker. Чтобы обновиться до {version}, повторно запустите способ установки или обновления, который вы использовали, — например, в Proxmox Community Scripts выполните обновление из консоли LXC:',
|
||||
'admin.update.wikiLink': 'Открыть руководство по обновлению',
|
||||
'admin.update.reloadHint': 'Перезагрузите страницу через несколько секунд.',
|
||||
'admin.tabs.permissions': 'Разрешения',
|
||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||
|
||||
@@ -317,9 +317,6 @@ const admin: TranslationStrings = {
|
||||
'admin.update.howTo': 'Nasıl Güncellenir?',
|
||||
'admin.update.dockerText':
|
||||
"TREK örneğiniz Docker'da çalışır. {version} sürümüne güncellemek için sunucunuzda aşağıdaki komutları çalıştırın:",
|
||||
'admin.update.nonDockerText':
|
||||
'Bu TREK örneği Docker üzerinde çalışmıyor. {version} sürümüne güncellemek için kullandığınız kurulum veya güncelleme yöntemini yeniden çalıştırın — örneğin Proxmox Community Scripts kullanıyorsanız güncellemeyi LXC konsolundan çalıştırın:',
|
||||
'admin.update.wikiLink': 'Güncelleme kılavuzunu aç',
|
||||
'admin.update.reloadHint': 'Lütfen birkaç saniye içinde sayfayı yeniden yükleyin.',
|
||||
'admin.tabs.permissions': 'İzinler',
|
||||
'admin.addons.catalog.journey.name': 'Seyahat',
|
||||
|
||||
@@ -270,9 +270,6 @@ const admin: TranslationStrings = {
|
||||
'admin.update.howTo': 'Як оновити',
|
||||
'admin.update.dockerText':
|
||||
'Ваш екземпляр TREK працює в Docker. Для оновлення до {version} виконайте ці команди на сервері:',
|
||||
'admin.update.nonDockerText':
|
||||
'Цей екземпляр TREK не працює в Docker. Щоб оновити до {version}, повторно запустіть метод встановлення або оновлення, який ви використовували, — наприклад, у Proxmox Community Scripts запустіть оновлення з консолі LXC:',
|
||||
'admin.update.wikiLink': 'Відкрити інструкцію з оновлення',
|
||||
'admin.update.reloadHint': 'Перезавантажте сторінку через кілька секунд.',
|
||||
'admin.tabs.permissions': 'Дозволи',
|
||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||
|
||||
@@ -301,9 +301,6 @@ const admin: TranslationStrings = {
|
||||
'admin.update.backupLink': '前往備份',
|
||||
'admin.update.howTo': '如何更新',
|
||||
'admin.update.dockerText': '你的 TREK 例項執行在 Docker 中。要更新到 {version},請在伺服器上執行以下命令:',
|
||||
'admin.update.nonDockerText':
|
||||
'此 TREK 例項並非執行在 Docker 中。要更新到 {version},請重新執行你當初使用的安裝或更新方式——例如在 Proxmox Community Scripts 上,請從 LXC 主控臺執行更新:',
|
||||
'admin.update.wikiLink': '開啟更新指南',
|
||||
'admin.update.reloadHint': '請在幾秒後重新整理頁面。',
|
||||
'admin.tabs.permissions': '許可權',
|
||||
'admin.addons.catalog.journey.name': '旅程',
|
||||
|
||||
@@ -261,8 +261,6 @@ const admin: TranslationStrings = {
|
||||
'admin.update.backupLink': '前往备份',
|
||||
'admin.update.howTo': '如何更新',
|
||||
'admin.update.dockerText': '你的 TREK 实例运行在 Docker 中。要更新到 {version},请在服务器上执行以下命令:',
|
||||
'admin.update.nonDockerText': '此 TREK 实例未运行在 Docker 中。要更新到 {version},请重新执行你当初使用的安装或更新方式——例如,在 Proxmox Community Scripts 上,从 LXC 控制台运行更新:',
|
||||
'admin.update.wikiLink': '打开更新指南',
|
||||
'admin.update.reloadHint': '请在几秒后刷新页面。',
|
||||
'admin.tabs.permissions': '权限',
|
||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||
|
||||
Reference in New Issue
Block a user