mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 05:11:46 +00:00
v3.1.1 bug fixes (#1228)
* fix(shared-view): render each leg of multi-leg flights correctly The read-only shared view showed the overall trip start/end airports and the first leg's flight number on every leg of a multi-leg flight. The Day Plan already expands legs (each carries __leg), but the renderer ignored it and read flat top-level metadata; the Bookings tab had the same bug. - Day Plan: use __leg for per-leg airline/flight number/route, plus dep-arr time - Bookings tab: list each leg via getFlightLegs() - unique React keys for multi-leg rows Closes #1219 * feat(pdf): add legs to pdf export * fix(demo): skip first-run admin seed in demo mode When DEMO_MODE is on, the demo seeder creates its own admin (admin@trek.app, username "admin") right after the generic seeds run. The first-run admin bootstrap was grabbing username "admin" first, so the demo seeder hit the UNIQUE(username) constraint and aborted before the demo user was ever created - which surfaced as a 500 "Demo user not found" on demo-login. Skip the generic admin bootstrap when demo mode owns the admin account. * fix(docker): ship the encryption-key migration script in the image The production image only copied server/dist, so the documented rotation command `node --import tsx scripts/migrate-encryption.ts` failed inside the container with a module-not-found error - the raw .ts was never present. The script runs via tsx straight from source and only pulls node builtins plus better-sqlite3 (both prod deps), so copying the single file into /app/server/scripts is enough to make the rotation work again. * fix(vacay): keep the mode toolbar above the mobile bottom nav The floating Vacation/Company toolbar was pinned at bottom-3 with z-30, so on mobile it landed in the same band as the fixed bottom nav (z-60) and got hidden behind it - and could scroll out of reach entirely. Pin it above the nav with the shared --bottom-nav-h variable (0px on desktop, so nothing changes there) and reserve matching space below the calendar grid so it never gets swallowed. * fix(dashboard): show the correct reservation date regardless of timezone The upcoming-reservations widget built the date with new Date(reservation_time) .toISOString(), which reinterprets the stored naive local time as UTC and can roll the displayed day forward in non-UTC timezones (e.g. a 23:30 reservation showing the next day). Read the date and time straight from the stored string parts via splitReservationDateTime, and format the time with the shared formatTime helper so it also honours the user's 12h/24h preference. * fix(atlas): cursor-following tooltips and removing countries from search Two related Atlas fixes: - Country tooltips were bound with sticky:false, which anchors them at the feature's bounds centre. For countries with overseas territories (e.g. France) that centre sits far out in the ocean, so the tooltip popped up nowhere near the area being hovered. Make them sticky so they track the cursor. - Selecting an already-visited country from the search bar always opened the "Mark / Bucket" dialog, with no way to remove it. Tiny countries like Vatican City or Singapore are hard to hit on the map, so search was the only way in. Mirror the map-click behaviour: a manually-marked country opens the Remove confirmation, a trip/place-backed one opens its detail. * fix(oidc): keep dots in generated usernames The OIDC username sanitizer stripped dots because they were missing from the allowed character class, so a name claim like "first.last" became "firstlast". Dots are valid usernames (the profile validator already allows ^[a-zA-Z0-9_.-]+$), so add the dot to the sanitizer. * fix(collab): show poll option labels in the UI The poll API formatted each option as { label, voters }, but the React poll component renders opt.text - so every option button came out blank. Emit text alongside label (kept for any other consumer) so options render again. * feat(backup): make the upload size limit configurable The restore upload was capped at a hard-coded 500 MB, so instances whose backup archive (uploads/ included) grew past that got a 413 "File too large" with no way to raise it. Add a BACKUP_UPLOAD_LIMIT_MB env var (default 500, invalid values warn and fall back), documented in .env.example. * feat(costs): create an expense from a booking, fix editing total-only items Replace the inline price + budget-category fields in the Transport and Reservation booking modals with a "Create expense" flow: the modal saves the booking, then opens the full Costs editor prefilled (name + category mapped from the booking type) and linked to the reservation. A booking with a linked expense shows it inline with edit / remove. Also fix the Costs editor so an expense with a recorded total but no payers (transport-derived or pre-rework items) opens with its amount, lets you set the currency, and saves - it previously showed 0 everywhere and could not be saved. Legacy / localized categories now map to the fixed keys, and changing a booking's type keeps its linked expense category in sync (unless it was manually set). - shared: reservation_id on budget create, typeToCostCategory helper, i18n keys - server: createBudgetItem stores reservation_id; keep total_price for payerless items; a booking update no longer wipes its linked expense and syncs the category on type change - client: shared BookingCostsSection, exported ExpenseModal with prefill and an editable total, page-level save-then-open wiring * test(reservations): align syncBudgetOnUpdate unit tests with no-wipe + type-sync The service now leaves a linked expense alone when no budget entry is on the payload (only an explicit total_price 0 deletes it) and syncs the category on a booking type change. Update the unit tests accordingly - the old "price cleared" case passed entry: undefined, which is now a no-op and left a mocked return queued that leaked into the next test. * fix(planner): keep a reservation on its day when edited (#1237) Editing a booking forced its day_id to the globally selected day, which is null when editing from the Book tab - so the booking lost its day and vanished from the Plan. Preserve the reservation own day_id on edit instead. * fix(planner): derive a booking day from its date when none is set (#1237) The client always sends day_id on a reservation update, so the server only derived it from reservation_time when the field was absent. A non-transport booking saved without a selected day (Book tab) therefore got day_id null and vanished from the Plan, even though its date matched a day. Derive the day from reservation_time whenever day_id is null, mirroring create. * fix(planner): let a booking's day follow its date when edited (#1237) Preserving the old day_id on edit left a re-dated booking on its previous start day while end_day_id followed the new date, so it spanned both. Stop sending day_id from the edit modal entirely - the server derives both ends from the booking's date (and keeps the current day when there is no date), so a re-dated booking moves cleanly to the matching day. * fix(atlas): keep the continent breakdown in sync on mark/unmark (#1225) The optimistic mark/unmark updates bumped the country total but never the per-continent counts, so the continent column froze until a full reload. Move the country to continent map into @trek/shared (single source for server and client) and adjust the matching continent count at every optimistic site: the country confirm flow plus the choose / region mark and region unmark handlers. * feat(admin): let admins set a default currency for new users Adds a currency picker to Admin > User Defaults. Stored as the default_currency user-default, so users who have not picked their own currency inherit it in Costs. * fix(atlas): give every sub-national region a distinct code (#1217) geoBoundaries fills shapeISO with the bare country code for some countries (every Spanish region got "ESP", every Chinese "CHN", also Chile/Oman), so marking one region lit up the whole country. build-atlas-geo.mjs now keeps shapeISO only when it is a real "XX-..." subdivision code and otherwise synthesizes a unique per-country id from the region name. Regenerated admin1.geojson.gz: Spain/China/ Chile/Oman now carry distinct region codes (countries with real codes, e.g. Germany, are unchanged). * fix(dashboard): never crash on a malformed reservation date A reservation with an invalid date blanked the whole My Trips page: the old Upcoming widget did new Date(value).toISOString(), which throws "Invalid time value" (fixed in #1222 by reading the string parts). Also guard splitDate so a bad date renders a dash instead of "Invalid Date" or throwing. * fix(airtrail): gate airtrail update behind a user setting, on airtrail update: rebuild payload from fresh data to prevent any data loss * fix(airtrail): add back missing tests * fix(costs): rework the cost panel UX wise and apply prettier on the shared package * chore(prettier) prettier this file * fix(airtrail): don't use cabin class as seat on import When an AirTrail flight has a cabin class but no seat number, the mapper fell back to the class for metadata.seat, so reservations showed e.g. "economy" as the seat. Use only the seat number; leave the seat blank otherwise. The class is still surfaced separately in the import picker. Closes #1246 * fix(airtrail): import scheduled flight times instead of actual AirTrail exposes both scheduled (departureScheduled/arrivalScheduled) and actual (departure/arrival) times. TREK read the actual times, so a delayed or early flight imported the wrong time for planning. Read the scheduled times on import and on poll-sync (both go through mapFlightToReservation); when a flight has no scheduled time, leave the clock blank (date preserved) rather than fabricating 00:00 or falling back to actual. The change-detection hash now tracks the scheduled values, so existing linked reservations re-sync once on the next poll. The opt-in writeback mirrors the read, pushing TREK edits to the scheduled fields so they round-trip. * fix(planner): hydrate per-assignment times when editing a place from the pool Times live per day-assignment, not on the pool place, so reopening a place from the Places panel / inspector showed empty Start/End fields (#1247). The editor now resolves a place's lone assignment when no day is in context and hydrates the fields from it; ambiguous (0 or 2+ days) edits hide the fields instead of showing non-persisting inputs. * fix(mcp): make write tools return client-valid, hydrated entities Audit of all write tools under server/src/mcp/tools (issue #1244 anchor). S1 (broken): - create_budget_item / create_budget_item_with_members now default the split to all trip members when member_ids omitted, so the entry passes the client save-gate instead of being member-less (#1244). - create_transport / update_transport backfill lat/lng/timezone for code-only flight endpoints (NOT NULL columns) and return a clean error for unresolvable endpoints instead of crashing. S2 (under-hydration): set_budget_item_members, create_journey, create_journey_entry, create_packing_bag, bulk_import_packing and update_vacay_plan now return the hydrated shape the matching read/REST route returns; bulk_import widened to accept bag/weight_grams/checked. S3 (parity): check_in_end added to accommodation tools; atlas mark_region_visited echoes the client shape; update_journey_entry/ update_journey_preferences, set_bag_members, set_packing_category_assignees, apply_packing_template return hydrated payloads; set_vacay_color echoes the color. Auth: save_packing_template now requires admin, matching the REST gate. Also refactors server/src/config.ts (JWT-secret handling). Adds getBudgetItem hydrated getter, exports EndpointInput, and MCP regression tests (incl. new tools-transports and tools-journey suites). * fix(mcp): fix ICS/maps/accommodation bugs, add settlement & template tools Bugs: - export_trip_ics: include flights that store times per-endpoint (local_date/local_time) instead of a top-level reservation_time - resolve_maps_url: follow redirects for cid=/share links and fall back to parsing the page body, all SSRF-guarded - link_hotel_accommodation: normalize accommodation_id (TEXT column) to an integer in the reservation read paths so it no longer returns "14.0" Gaps: - packing: save_packing_template returns the new template id; add list_packing_templates (read) and delete_packing_template (admin) - budget: update_budget_item accepts payers/member_ids; clarify create/ update/members descriptions to ask which members share the expense and who paid - budget: add settlement tools — get_settlement_summary, list_settlements, create/update/delete_settlement (budget_edit, mirrors REST + WS events) * chore: bump nodemailer * chore: bump multer --------- Co-authored-by: Maurice <mauriceboe@icloud.com>
This commit is contained in:
@@ -85,6 +85,10 @@ COPY --from=server-builder /app/server/dist ./server/dist
|
|||||||
COPY --from=server-builder /app/server/assets ./server/assets
|
COPY --from=server-builder /app/server/assets ./server/assets
|
||||||
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
|
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
|
||||||
COPY server/tsconfig.json ./server/
|
COPY server/tsconfig.json ./server/
|
||||||
|
# Encryption-key rotation is run on demand via tsx (a prod dep) straight from the
|
||||||
|
# raw .ts source — it never enters dist, so it must be copied in explicitly or
|
||||||
|
# `node --import tsx scripts/migrate-encryption.ts` fails with module-not-found.
|
||||||
|
COPY server/scripts/migrate-encryption.ts ./server/scripts/migrate-encryption.ts
|
||||||
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||||
COPY --from=client-builder /app/client/dist ./server/public
|
COPY --from=client-builder /app/client/dist ./server/public
|
||||||
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
|
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
|
||||||
|
|||||||
@@ -489,7 +489,7 @@ export const addonsApi = {
|
|||||||
|
|
||||||
export const airtrailApi = {
|
export const airtrailApi = {
|
||||||
getSettings: () => apiClient.get('/integrations/airtrail/settings').then(r => r.data),
|
getSettings: () => apiClient.get('/integrations/airtrail/settings').then(r => r.data),
|
||||||
saveSettings: (data: { url: string; apiKey?: string; allowInsecureTls?: boolean }) =>
|
saveSettings: (data: { url: string; apiKey?: string; allowInsecureTls?: boolean; writeEnabled?: boolean }) =>
|
||||||
apiClient.put('/integrations/airtrail/settings', data).then(r => r.data),
|
apiClient.put('/integrations/airtrail/settings', data).then(r => r.data),
|
||||||
status: () => apiClient.get('/integrations/airtrail/status').then(r => r.data),
|
status: () => apiClient.get('/integrations/airtrail/status').then(r => r.data),
|
||||||
test: (data: { url?: string; apiKey?: string; allowInsecureTls?: boolean }) =>
|
test: (data: { url?: string; apiKey?: string; allowInsecureTls?: boolean }) =>
|
||||||
@@ -595,6 +595,7 @@ export const budgetApi = {
|
|||||||
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
|
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
|
||||||
settlement: (tripId: number | string, base?: string) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base } } : undefined).then(r => r.data),
|
settlement: (tripId: number | string, base?: string) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base } } : undefined).then(r => r.data),
|
||||||
createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
|
createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
|
||||||
|
updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data),
|
||||||
deleteSettlement: (tripId: number | string, settlementId: number) => apiClient.delete(`/trips/${tripId}/budget/settlements/${settlementId}`).then(r => r.data),
|
deleteSettlement: (tripId: number | string, settlementId: number) => apiClient.delete(`/trips/${tripId}/budget/settlements/${settlementId}`).then(r => r.data),
|
||||||
reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data),
|
reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data),
|
||||||
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data),
|
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useToast } from '../shared/Toast'
|
|||||||
import Section from '../Settings/Section'
|
import Section from '../Settings/Section'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import { MapView } from '../Map/MapView'
|
import { MapView } from '../Map/MapView'
|
||||||
|
import { CURRENCIES, SYMBOLS } from '../Budget/BudgetPanel.constants'
|
||||||
import type { Place } from '../../types'
|
import type { Place } from '../../types'
|
||||||
|
|
||||||
const MAP_PRESETS = [
|
const MAP_PRESETS = [
|
||||||
@@ -20,6 +21,7 @@ type Defaults = {
|
|||||||
temperature_unit?: string
|
temperature_unit?: string
|
||||||
dark_mode?: string | boolean
|
dark_mode?: string | boolean
|
||||||
time_format?: string
|
time_format?: string
|
||||||
|
default_currency?: string
|
||||||
blur_booking_codes?: boolean
|
blur_booking_codes?: boolean
|
||||||
map_tile_url?: string
|
map_tile_url?: string
|
||||||
map_provider?: string
|
map_provider?: string
|
||||||
@@ -226,6 +228,23 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
|||||||
))}
|
))}
|
||||||
</OptionRow>
|
</OptionRow>
|
||||||
|
|
||||||
|
{/* Default Currency */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||||
|
{t('settings.currency')} <ResetButton field="default_currency" />
|
||||||
|
</label>
|
||||||
|
<CustomSelect
|
||||||
|
value={defaults.default_currency || ''}
|
||||||
|
onChange={(value: string) => { if (value) save({ default_currency: value }) }}
|
||||||
|
placeholder={t('settings.currency')}
|
||||||
|
searchable
|
||||||
|
options={CURRENCIES.map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))}
|
||||||
|
size="sm"
|
||||||
|
style={{ maxWidth: 240 }}
|
||||||
|
/>
|
||||||
|
<p className="text-xs mt-1 text-content-faint">{t('settings.currencyHint')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Blur Booking Codes */}
|
{/* Blur Booking Codes */}
|
||||||
<OptionRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
|
<OptionRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
|
||||||
{([
|
{([
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
// FE-COMP-COSTS: settlements surfaced inline in the Costs ledger (issue #1241)
|
||||||
|
import { render, screen, waitFor } from '../../../tests/helpers/render'
|
||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { server } from '../../../tests/helpers/msw/server'
|
||||||
|
import { useAuthStore } from '../../store/authStore'
|
||||||
|
import { useTripStore } from '../../store/tripStore'
|
||||||
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||||
|
import { buildUser, buildTrip, buildBudgetItem } from '../../../tests/helpers/factories'
|
||||||
|
import CostsPanel from './CostsPanel'
|
||||||
|
|
||||||
|
const tripMembers = [
|
||||||
|
{ id: 1, username: 'alice', avatar_url: null },
|
||||||
|
{ id: 2, username: 'bob', avatar_url: null },
|
||||||
|
]
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAllStores()
|
||||||
|
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true })
|
||||||
|
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CostsPanel — settlements in the ledger', () => {
|
||||||
|
it('renders a settle-up payment as a ledger row with an undo action', async () => {
|
||||||
|
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }), total_price: 90, expense_date: '2025-06-15' }
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||||
|
http.get('/api/trips/1/budget/settlement', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
balances: [],
|
||||||
|
flows: [],
|
||||||
|
settlements: [
|
||||||
|
{ id: 7, trip_id: 1, from_user_id: 2, to_user_id: 1, amount: 30, created_at: '2025-06-16 10:00:00', from_username: 'bob', to_username: 'alice' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
),
|
||||||
|
)
|
||||||
|
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||||
|
|
||||||
|
// The expense and the settlement (payment) both appear in the unified ledger.
|
||||||
|
await screen.findByText('Dinner')
|
||||||
|
await screen.findByText('Payment')
|
||||||
|
// The payment row exposes an inline undo (no need to open a separate History modal).
|
||||||
|
expect(screen.getByTitle('Undo')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('records a manual payment via the Add payment button', 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/settlements', async ({ request }) => {
|
||||||
|
posted = await request.json() as Record<string, unknown>
|
||||||
|
return HttpResponse.json({ settlement: { id: 1, ...posted } })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
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 payment' }))
|
||||||
|
await user.type(await screen.findByPlaceholderText('0.00'), '25')
|
||||||
|
// The footer submit is the second "Add payment" control once the modal is open.
|
||||||
|
const addButtons = screen.getAllByRole('button', { name: 'Add payment' })
|
||||||
|
const submit = addButtons[addButtons.length - 1]
|
||||||
|
await user.click(submit)
|
||||||
|
await waitFor(() => expect(posted).toMatchObject({ amount: 25 }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides payment rows while a text search is active', async () => {
|
||||||
|
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }), total_price: 90, expense_date: '2025-06-15' }
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||||
|
http.get('/api/trips/1/budget/settlement', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
balances: [],
|
||||||
|
flows: [],
|
||||||
|
settlements: [
|
||||||
|
{ id: 7, trip_id: 1, from_user_id: 2, to_user_id: 1, amount: 30, created_at: '2025-06-16 10:00:00', from_username: 'bob', to_username: 'alice' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const { default: userEvent } = await import('@testing-library/user-event')
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||||
|
|
||||||
|
await screen.findByText('Payment')
|
||||||
|
await user.type(screen.getByPlaceholderText('Search expenses…'), 'Dinner')
|
||||||
|
// Payment rows have no name, so a search hides them while the matching expense stays.
|
||||||
|
expect(screen.queryByText('Payment')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Dinner')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('auto-splits the total across participants and rebalances a pinned amount on save', 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: 'Dinner' }), id: 5 } })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
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…'), 'Dinner')
|
||||||
|
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')
|
||||||
|
// Pin the first participant to 30 → the other non-pinned field rebalances to 70.
|
||||||
|
await user.clear(nums()[1]); await user.type(nums()[1], '30')
|
||||||
|
await waitFor(() => expect(nums()[2].value).toBe('70'))
|
||||||
|
|
||||||
|
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(100)
|
||||||
|
expect(posted!.payers).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ user_id: 1, amount: 30 }),
|
||||||
|
expect.objectContaining({ user_id: 2, amount: 70 }),
|
||||||
|
]))
|
||||||
|
})
|
||||||
|
|
||||||
|
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(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||||
|
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||||
|
)
|
||||||
|
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||||
|
await screen.findByText('Hotel')
|
||||||
|
expect(screen.getByText('Unfinished')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, Check, RotateCcw, History, Pencil, Trash2 } from 'lucide-react'
|
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2 } from 'lucide-react'
|
||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import { useAuthStore } from '../../store/authStore'
|
import { useAuthStore } from '../../store/authStore'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
@@ -39,6 +39,12 @@ interface SettlementData {
|
|||||||
settlements: Settlement[]
|
settlements: Settlement[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One row in the unified Costs ledger — either an expense or a settle-up payment,
|
||||||
|
// carrying the date used to group it by day.
|
||||||
|
type LedgerEntry =
|
||||||
|
| { kind: 'expense'; date: string; e: BudgetItem }
|
||||||
|
| { kind: 'payment'; date: string; s: Settlement }
|
||||||
|
|
||||||
const round2 = (n: number) => Math.round(n * 100) / 100
|
const round2 = (n: number) => Math.round(n * 100) / 100
|
||||||
const FIELD_H = 40 // shared height for the amount / currency / day row in the modal
|
const FIELD_H = 40 // shared height for the amount / currency / day row in the modal
|
||||||
|
|
||||||
@@ -62,9 +68,10 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
|||||||
const [settlement, setSettlement] = useState<SettlementData | null>(null)
|
const [settlement, setSettlement] = useState<SettlementData | null>(null)
|
||||||
const [filter, setFilter] = useState<'all' | 'mine' | 'owed'>('all')
|
const [filter, setFilter] = useState<'all' | 'mine' | 'owed'>('all')
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [histOpen, setHistOpen] = useState(false)
|
|
||||||
const [modalOpen, setModalOpen] = useState(false)
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
const [editing, setEditing] = useState<BudgetItem | null>(null)
|
const [editing, setEditing] = useState<BudgetItem | null>(null)
|
||||||
|
const [editingSettlement, setEditingSettlement] = useState<Settlement | null>(null)
|
||||||
|
const [addingPayment, setAddingPayment] = useState(false)
|
||||||
|
|
||||||
const people = tripMembers
|
const people = tripMembers
|
||||||
const personById = useCallback((id: number) => people.find(p => p.id === id), [people])
|
const personById = useCallback((id: number) => people.find(p => p.id === id), [people])
|
||||||
@@ -122,21 +129,37 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
|||||||
return list
|
return list
|
||||||
}, [budgetItems, filter, search, me])
|
}, [budgetItems, filter, search, me])
|
||||||
|
|
||||||
|
// Settlements ("payments") shown inline in the ledger. They have no name, so a
|
||||||
|
// text search hides them; they're excluded from the "owed" expense filter and,
|
||||||
|
// under "mine", only show transfers I'm part of.
|
||||||
|
const filteredSettlements = useMemo(() => {
|
||||||
|
if (search.trim()) return []
|
||||||
|
if (filter === 'owed') return []
|
||||||
|
let list = settlement?.settlements || []
|
||||||
|
if (filter === 'mine') list = list.filter(s => s.from_user_id === me || s.to_user_id === me)
|
||||||
|
return list
|
||||||
|
}, [settlement, filter, search, me])
|
||||||
|
|
||||||
const dayGroups = useMemo(() => {
|
const dayGroups = useMemo(() => {
|
||||||
const groups: { day: string; items: BudgetItem[] }[] = []
|
const entries: LedgerEntry[] = [
|
||||||
const labelOf = (e: BudgetItem) => {
|
...filtered.map(e => ({ kind: 'expense' as const, date: e.expense_date || '', e })),
|
||||||
if (!e.expense_date) return t('costs.noDate')
|
...filteredSettlements.map(s => ({ kind: 'payment' as const, date: (s.created_at || '').slice(0, 10), s })),
|
||||||
try { return new Date(e.expense_date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) } catch { return e.expense_date }
|
]
|
||||||
|
const labelOf = (date: string) => {
|
||||||
|
if (!date) return t('costs.noDate')
|
||||||
|
try { return new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) } catch { return date }
|
||||||
}
|
}
|
||||||
const sorted = filtered.slice().sort((a, b) => (b.expense_date || '').localeCompare(a.expense_date || ''))
|
// Newest day first; within a day, expenses before payments (insertion order).
|
||||||
for (const e of sorted) {
|
const sorted = entries.slice().sort((a, b) => (b.date || '').localeCompare(a.date || ''))
|
||||||
const day = labelOf(e)
|
const groups: { day: string; entries: LedgerEntry[] }[] = []
|
||||||
|
for (const en of sorted) {
|
||||||
|
const day = labelOf(en.date)
|
||||||
let g = groups.find(x => x.day === day)
|
let g = groups.find(x => x.day === day)
|
||||||
if (!g) { g = { day, items: [] }; groups.push(g) }
|
if (!g) { g = { day, entries: [] }; groups.push(g) }
|
||||||
g.items.push(e)
|
g.entries.push(en)
|
||||||
}
|
}
|
||||||
return groups
|
return groups
|
||||||
}, [filtered, locale, t])
|
}, [filtered, filteredSettlements, locale, t])
|
||||||
|
|
||||||
// ── settle actions ──────────────────────────────────────────────────────
|
// ── settle actions ──────────────────────────────────────────────────────
|
||||||
const settleFlow = async (fromId: number, toId: number, amount: number) => {
|
const settleFlow = async (fromId: number, toId: number, amount: number) => {
|
||||||
@@ -280,14 +303,16 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
|||||||
{search ? t('costs.noMatch') : t('costs.emptyText')}
|
{search ? t('costs.noMatch') : t('costs.emptyText')}
|
||||||
</div>
|
</div>
|
||||||
) : dayGroups.map(g => {
|
) : dayGroups.map(g => {
|
||||||
const dtot = g.items.reduce((a, e) => a + baseTotal(e), 0)
|
const dtot = g.entries.reduce((a, en) => en.kind === 'expense' ? a + baseTotal(en.e) : a, 0)
|
||||||
return (
|
return (
|
||||||
<div key={g.day} style={{ marginBottom: 22 }}>
|
<div key={g.day} style={{ marginBottom: 22 }}>
|
||||||
<div className={labelCls} style={{ display: 'flex', alignItems: 'center', margin: '0 0 10px 4px' }}>
|
<div className={labelCls} style={{ display: 'flex', alignItems: 'center', margin: '0 0 10px 4px' }}>
|
||||||
{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 12 }}>{t('costs.spent', { amount: fmt(dtot) })}</span>
|
{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 12 }}>{t('costs.spent', { amount: fmt(dtot) })}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
{g.items.map(e => <ExpenseRow key={e.id} e={e} />)}
|
{g.entries.map(en => en.kind === 'expense'
|
||||||
|
? <ExpenseRow key={'e' + en.e.id} e={en.e} />
|
||||||
|
: <SettlementRow key={'s' + en.s.id} s={en.s} />)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -300,11 +325,13 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
|||||||
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
|
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||||
<div className={labelCls}>{t('costs.settleUp')} · <span className="text-content">{(settlement?.flows || []).length}</span></div>
|
<div className={labelCls}>{t('costs.settleUp')} · <span className="text-content">{(settlement?.flows || []).length}</span></div>
|
||||||
<button disabled={!(settlement?.settlements || []).length} onClick={() => setHistOpen(true)}
|
{canEdit && (
|
||||||
className="text-content-muted bg-surface-secondary border border-edge disabled:opacity-40"
|
<button onClick={() => setAddingPayment(true)}
|
||||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 9px', borderRadius: 8, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
className="text-content-muted bg-surface-secondary border border-edge"
|
||||||
<History size={13} /> {t('costs.history')}{(settlement?.settlements || []).length ? ` (${settlement!.settlements.length})` : ''}
|
style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 9px', borderRadius: 8, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||||
</button>
|
<Plus size={13} /> {t('costs.addPayment')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<SettleFlows />
|
<SettleFlows />
|
||||||
</div>
|
</div>
|
||||||
@@ -330,9 +357,11 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
|||||||
onSaved={() => { setModalOpen(false); loadBudgetItems(tripId); loadSettlement() }} />
|
onSaved={() => { setModalOpen(false); loadBudgetItems(tripId); loadSettlement() }} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Modal isOpen={histOpen} onClose={() => setHistOpen(false)} title={t('costs.settleHistory')} size="md">
|
{(editingSettlement || addingPayment) && (
|
||||||
<SettleHistory settlements={settlement?.settlements || []} fmt={fmt} Avatar={Avatar} name={personName} onUndo={undoSettlement} canEdit={canEdit} />
|
<SettlementModal tripId={tripId} people={people} me={me} editing={editingSettlement}
|
||||||
</Modal>
|
onClose={() => { setEditingSettlement(null); setAddingPayment(false) }}
|
||||||
|
onSaved={() => { setEditingSettlement(null); setAddingPayment(false); loadSettlement() }} />
|
||||||
|
)}
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
.costs-root {
|
.costs-root {
|
||||||
@@ -438,7 +467,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
|||||||
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
|
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, gap: 8 }}>
|
||||||
<div className="text-content" style={{ fontSize: 19, fontWeight: 700, letterSpacing: '-0.02em', display: 'flex', alignItems: 'baseline', gap: 8 }}>{t('costs.settleUp')} <span className="text-content-faint" style={{ fontSize: 12, fontWeight: 500 }}>{(settlement?.flows || []).length}</span></div>
|
<div className="text-content" style={{ fontSize: 19, fontWeight: 700, letterSpacing: '-0.02em', display: 'flex', alignItems: 'baseline', gap: 8 }}>{t('costs.settleUp')} <span className="text-content-faint" style={{ fontSize: 12, fontWeight: 500 }}>{(settlement?.flows || []).length}</span></div>
|
||||||
<button disabled={!(settlement?.settlements || []).length} onClick={() => setHistOpen(true)} className="text-content-muted bg-surface-card border border-edge disabled:opacity-40" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 9, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><History size={13} /> {t('costs.history')}</button>
|
{canEdit && (
|
||||||
|
<button onClick={() => setAddingPayment(true)} className="text-content-muted bg-surface-card border border-edge" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 9, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><Plus size={13} /> {t('costs.addPayment')}</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<SettleFlows />
|
<SettleFlows />
|
||||||
</div>
|
</div>
|
||||||
@@ -458,11 +489,13 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
|||||||
{dayGroups.length === 0
|
{dayGroups.length === 0
|
||||||
? <div className="text-content-faint" style={{ textAlign: 'center', padding: '36px 16px', fontSize: 13 }}>{search ? t('costs.noMatch') : t('costs.emptyText')}</div>
|
? <div className="text-content-faint" style={{ textAlign: 'center', padding: '36px 16px', fontSize: 13 }}>{search ? t('costs.noMatch') : t('costs.emptyText')}</div>
|
||||||
: dayGroups.map(g => {
|
: dayGroups.map(g => {
|
||||||
const dtot = g.items.reduce((a, e) => a + baseTotal(e), 0)
|
const dtot = g.entries.reduce((a, en) => en.kind === 'expense' ? a + baseTotal(en.e) : a, 0)
|
||||||
return (
|
return (
|
||||||
<div key={g.day} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div key={g.day} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
<div className={labelCls} style={{ display: 'flex', alignItems: 'center', padding: '0 2px' }}>{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 11.5 }}>{t('costs.spent', { amount: fmt(dtot) })}</span></div>
|
<div className={labelCls} style={{ display: 'flex', alignItems: 'center', padding: '0 2px' }}>{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 11.5 }}>{t('costs.spent', { amount: fmt(dtot) })}</span></div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{g.items.map(e => <ExpenseRow key={e.id} e={e} />)}</div>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{g.entries.map(en => en.kind === 'expense'
|
||||||
|
? <ExpenseRow key={'e' + en.e.id} e={en.e} />
|
||||||
|
: <SettlementRow key={'s' + en.s.id} s={en.s} />)}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -490,11 +523,22 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
|||||||
const cur = curOf(e)
|
const cur = curOf(e)
|
||||||
const payers = (e.payers || []).filter(p => p.amount > 0)
|
const payers = (e.payers || []).filter(p => p.amount > 0)
|
||||||
const net = round2(myPaidOf(e) - myShareOf(e))
|
const net = round2(myPaidOf(e) - myShareOf(e))
|
||||||
|
// "Unfinished": a recorded total nobody has paid yet — counts toward the trip
|
||||||
|
// total but stays out of settlements until who-paid is filled in.
|
||||||
|
const isUnfinished = baseTotal(e) > 0 && payers.length === 0
|
||||||
return (
|
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' }}>
|
<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={{ width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}><Icon size={21} /></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={{ minWidth: 0 }}>
|
||||||
<div className="text-content" style={{ fontSize: 15, fontWeight: 600, marginBottom: 6 }}>{e.name}</div>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 6 }}>
|
||||||
|
<span className="text-content" style={{ fontSize: 15, fontWeight: 600 }}>{e.name}</span>
|
||||||
|
{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')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{payers.length > 0 && (
|
{payers.length > 0 && (
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginBottom: 5 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginBottom: 5 }}>
|
||||||
{payers.map(p => (
|
{payers.map(p => (
|
||||||
@@ -514,7 +558,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
|||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}>
|
||||||
<div style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
<div style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||||
<div className="text-content" style={{ fontSize: 18, fontWeight: 600 }}>{fmt(baseTotal(e))}</div>
|
<div className="text-content" style={{ fontSize: 18, fontWeight: 600 }}>{fmt(baseTotal(e))}</div>
|
||||||
{(e.members || []).length > 0 && Math.abs(net) > 0.01 && (
|
{!isUnfinished && (e.members || []).length > 0 && Math.abs(net) > 0.01 && (
|
||||||
<div style={{ fontSize: 12, marginTop: 2, fontWeight: 500, whiteSpace: 'nowrap', color: net > 0 ? '#16a34a' : '#dc2626' }}>
|
<div style={{ fontSize: 12, marginTop: 2, fontWeight: 500, whiteSpace: 'nowrap', color: net > 0 ? '#16a34a' : '#dc2626' }}>
|
||||||
{net > 0 ? t('costs.youLent', { amount: fmt(net) }) : t('costs.youBorrowed', { amount: fmt(-net) })}
|
{net > 0 ? t('costs.youLent', { amount: fmt(net) }) : t('costs.youBorrowed', { amount: fmt(-net) })}
|
||||||
</div>
|
</div>
|
||||||
@@ -531,6 +575,32 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A settle-up payment as a ledger row — visually distinct from an expense, with
|
||||||
|
// inline edit + undo (reuses deleteSettlement) so it isn't buried in a modal.
|
||||||
|
function SettlementRow({ s }: { s: Settlement }) {
|
||||||
|
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={{ width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: 'rgba(22,163,74,0.12)', color: '#16a34a' }}><ArrowLeftRight size={21} /></span>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div className="text-content" style={{ fontSize: 15, fontWeight: 600, marginBottom: 6 }}>{t('costs.payment')}</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 }} title={`${personName(s.from_user_id)} → ${personName(s.to_user_id)}`}>
|
||||||
|
<Avatar id={s.from_user_id} size={20} /><ArrowRight size={13} className="text-content-faint" /><Avatar id={s.to_user_id} size={20} />
|
||||||
|
<span className="text-content-faint" style={{ fontSize: 12, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{personName(s.from_user_id)} → {personName(s.to_user_id)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}>
|
||||||
|
<div className="text-content" style={{ fontSize: 18, fontWeight: 600, whiteSpace: 'nowrap' }}>{fmt(s.amount)}</div>
|
||||||
|
{canEdit && (
|
||||||
|
<div className="exp-actions" style={{ display: 'flex', flexDirection: 'column', gap: 6, flexShrink: 0 }}>
|
||||||
|
<button title={t('common.edit')} onClick={() => setEditingSettlement(s)} className="bg-surface-secondary border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 999, cursor: 'pointer' }}><Pencil size={13} /></button>
|
||||||
|
<button title={t('costs.undo')} onClick={() => undoSettlement(s.id)} className="bg-surface-secondary border border-edge" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 999, cursor: 'pointer', color: '#dc2626' }}><RotateCcw size={13} /></button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function BalancesList({ balances }: { balances: SettlementData['balances'] }) {
|
function BalancesList({ balances }: { balances: SettlementData['balances'] }) {
|
||||||
const rows = people.map(p => balances.find(b => b.user_id === p.id) || { user_id: p.id, username: p.username, avatar_url: null, balance: 0 })
|
const rows = people.map(p => balances.find(b => b.user_id === p.id) || { user_id: p.id, username: p.username, avatar_url: null, balance: 0 })
|
||||||
const max = Math.max(1, ...rows.map(r => Math.abs(r.balance)))
|
const max = Math.max(1, ...rows.map(r => Math.abs(r.balance)))
|
||||||
@@ -633,37 +703,75 @@ function FlowPills({ ids, lead, Avatar, name }: { ids: number[]; lead: string; A
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SettleHistory({ settlements, fmt, Avatar, name, onUndo, canEdit }: {
|
// Add or edit a settle-up payment (from / to / amount). Reachable inline from the
|
||||||
settlements: Settlement[]; fmt: (v: number) => string; Avatar: (p: { id: number; size?: number }) => React.JSX.Element; name: (id: number) => string; onUndo: (id: number) => void; canEdit: boolean
|
// ledger row and from a manual "Add payment" button, so recording "I sent money to
|
||||||
|
// X" works the same whether or not there's an outstanding expense behind it.
|
||||||
|
function SettlementModal({ tripId, people, me, editing, onClose, onSaved }: {
|
||||||
|
tripId: number; people: TripMember[]; me: number; editing: Settlement | null; onClose: () => void; onSaved: () => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
if (settlements.length === 0) return <div className="text-content-faint" style={{ textAlign: 'center', padding: 30, fontSize: 13 }}>{t('costs.noSettlements')}</div>
|
const toast = useToast()
|
||||||
const total = settlements.reduce((a, s) => a + s.amount, 0)
|
const otherDefault = people.find(p => p.id !== me)?.id ?? me
|
||||||
|
const [fromId, setFromId] = useState<string>(String(editing?.from_user_id ?? me))
|
||||||
|
const [toId, setToId] = useState<string>(String(editing?.to_user_id ?? otherDefault))
|
||||||
|
const [amount, setAmount] = useState<string>(editing ? String(editing.amount) : '')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const amt = parseFloat(amount) || 0
|
||||||
|
const valid = amt > 0 && fromId !== toId
|
||||||
|
const opts = people.map(p => ({ value: String(p.id), label: p.id === me ? t('costs.you') : p.username }))
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!valid) return
|
||||||
|
setSaving(true)
|
||||||
|
const data = { from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt }
|
||||||
|
try {
|
||||||
|
if (editing) await budgetApi.updateSettlement(tripId, editing.id, data)
|
||||||
|
else await budgetApi.createSettlement(tripId, data)
|
||||||
|
onSaved()
|
||||||
|
} catch { toast.error(t('common.unknownError')) } finally { setSaving(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputCls = 'w-full bg-surface-input border border-edge text-content'
|
||||||
|
const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<Modal isOpen onClose={onClose} title={editing ? t('costs.editPayment') : t('costs.addPayment')} size="md"
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '12px 14px', borderRadius: 12, marginBottom: 14, background: 'rgba(22,163,74,0.1)', color: '#16a34a', fontWeight: 600, fontSize: 13 }}>
|
footer={
|
||||||
<span>{t('costs.paymentsSettled', { count: settlements.length })}</span><span>{fmt(total)}</span>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||||
|
<button onClick={onClose} className="text-content-muted border border-edge" style={{ padding: '8px 16px', borderRadius: 10, background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
|
||||||
|
<button onClick={save} disabled={!valid || saving} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 0, fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: !valid || saving ? 0.5 : 1 }}>{editing ? t('common.save') : t('costs.addPayment')}</button>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>{t('costs.from')}</label>
|
||||||
|
<CustomSelect value={fromId} onChange={v => setFromId(String(v))} options={opts} style={{ width: '100%' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>{t('costs.to')}</label>
|
||||||
|
<CustomSelect value={toId} onChange={v => setToId(String(v))} options={opts} style={{ width: '100%' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>{t('costs.amount')}</label>
|
||||||
|
<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>
|
</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
</Modal>
|
||||||
{settlements.map(s => (
|
|
||||||
<div key={s.id} className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '12px 14px', borderRadius: 12 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }} title={`${name(s.from_user_id)} → ${name(s.to_user_id)}`}>
|
|
||||||
<Avatar id={s.from_user_id} size={30} /><ArrowRight size={15} className="text-content-faint" /><Avatar id={s.to_user_id} size={30} />
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
||||||
<span className="text-content" style={{ fontSize: 14, fontWeight: 600 }}>{fmt(s.amount)}</span>
|
|
||||||
{canEdit && <button onClick={() => onUndo(s.id)} className="bg-surface-card border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 8, fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><RotateCcw size={12} /> {t('costs.undo')}</button>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Add / edit expense modal ───────────────────────────────────────────────
|
// ── Add / edit expense modal ───────────────────────────────────────────────
|
||||||
function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: {
|
export interface ExpensePrefill {
|
||||||
tripId: number; base: string; people: TripMember[]; me: number; editing: BudgetItem | null; onClose: () => void; onSaved: () => void
|
name?: string
|
||||||
|
category?: string
|
||||||
|
amount?: number
|
||||||
|
reservationId?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClose, onSaved }: {
|
||||||
|
tripId: number; base: string; people: TripMember[]; me: number; editing: BudgetItem | null; prefill?: ExpensePrefill; onClose: () => void; onSaved: () => void
|
||||||
}) {
|
}) {
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -671,34 +779,94 @@ function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: {
|
|||||||
const { convert } = useExchangeRates(base)
|
const { convert } = useExchangeRates(base)
|
||||||
const sym = (c: string) => SYMBOLS[c] || (c + ' ')
|
const sym = (c: string) => SYMBOLS[c] || (c + ' ')
|
||||||
|
|
||||||
const [name, setName] = useState(editing?.name || '')
|
const [name, setName] = useState(editing?.name || prefill?.name || '')
|
||||||
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : 'food')
|
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : (prefill?.category || 'food'))
|
||||||
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
|
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
|
||||||
const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10))
|
const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10))
|
||||||
const [payers, setPayers] = useState<Record<number, string>>(() => {
|
// One participant list: a person is "in" the split and may have paid an amount.
|
||||||
|
// Entering the total auto-distributes it equally across the non-pinned participants;
|
||||||
|
// touching an amount pins it and the rest rebalance so the paid amounts always sum
|
||||||
|
// back to the total. Leaving every amount blank = an unfinished expense (counts
|
||||||
|
// toward the trip total only, never settlements, until who-paid is filled in).
|
||||||
|
const [total, setTotal] = useState<string>(() => {
|
||||||
|
if (editing) return editing.total_price ? String(editing.total_price) : ''
|
||||||
|
if (prefill?.amount != null) return String(prefill.amount)
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
const [participants, setParticipants] = useState<Set<number>>(() =>
|
||||||
|
editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id)))
|
||||||
|
const [paid, setPaid] = useState<Record<number, string>>(() => {
|
||||||
const m: Record<number, string> = {}
|
const m: Record<number, string> = {}
|
||||||
for (const p of editing?.payers || []) m[p.user_id] = String(p.amount)
|
for (const p of editing?.payers || []) if (p.amount > 0) m[p.user_id] = String(p.amount)
|
||||||
return m
|
return m
|
||||||
})
|
})
|
||||||
const [split, setSplit] = useState<Set<number>>(() =>
|
// Amounts the user pinned by typing — kept out of the auto-rebalance. Existing
|
||||||
editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id)))
|
// payer amounts load as pinned so opening an expense never reshuffles them.
|
||||||
|
const [dirty, setDirty] = useState<Set<number>>(() =>
|
||||||
|
new Set((editing?.payers || []).filter(p => p.amount > 0).map(p => p.user_id)))
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
const payersTotal = Object.values(payers).reduce((a, v) => a + (parseFloat(v) || 0), 0)
|
const totalNum = parseFloat(total) || 0
|
||||||
const each = split.size > 0 ? payersTotal / split.size : 0
|
const paidSum = round2([...participants].reduce((a, id) => a + (parseFloat(paid[id]) || 0), 0))
|
||||||
const valid = name.trim().length > 0 && split.size > 0 && payersTotal > 0
|
const paidEntered = paidSum > 0
|
||||||
|
const balanced = Math.abs(paidSum - totalNum) < 0.01
|
||||||
|
const each = participants.size > 0 ? totalNum / participants.size : 0
|
||||||
|
const valid = name.trim().length > 0 && totalNum > 0 && participants.size > 0 && (!paidEntered || balanced)
|
||||||
|
|
||||||
|
// Spread `amount` across `n` people in whole cents so the parts sum back exactly.
|
||||||
|
const splitCents = (amount: number, n: number): number[] => {
|
||||||
|
if (n <= 0) return []
|
||||||
|
const cents = Math.max(0, Math.round(amount * 100))
|
||||||
|
const base = Math.floor(cents / n), rem = cents - base * n
|
||||||
|
return Array.from({ length: n }, (_, i) => (base + (i < rem ? 1 : 0)) / 100)
|
||||||
|
}
|
||||||
|
// Recompute the non-pinned participants so every paid amount sums to the total.
|
||||||
|
const rebalance = (paidMap: Record<number, string>, dirtySet: Set<number>, parts: Set<number>, totalVal: number): Record<number, string> => {
|
||||||
|
const ids = [...parts]
|
||||||
|
const free = ids.filter(id => !dirtySet.has(id))
|
||||||
|
if (free.length === 0) return paidMap
|
||||||
|
const pinnedSum = ids.filter(id => dirtySet.has(id)).reduce((a, id) => a + (parseFloat(paidMap[id]) || 0), 0)
|
||||||
|
const shares = splitCents(totalVal - pinnedSum, free.length)
|
||||||
|
const next = { ...paidMap }
|
||||||
|
free.forEach((id, i) => { next[id] = shares[i] ? String(shares[i]) : '' })
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTotalChange = (v: string) => {
|
||||||
|
setTotal(v)
|
||||||
|
setPaid(prev => rebalance(prev, dirty, participants, parseFloat(v) || 0))
|
||||||
|
}
|
||||||
|
const onPaidChange = (id: number, v: string) => {
|
||||||
|
const nextDirty = new Set(dirty); nextDirty.add(id)
|
||||||
|
setDirty(nextDirty)
|
||||||
|
setPaid(prev => rebalance({ ...prev, [id]: v }, nextDirty, participants, totalNum))
|
||||||
|
}
|
||||||
|
const toggleParticipant = (id: number) => {
|
||||||
|
const nextParts = new Set(participants), nextDirty = new Set(dirty), nextPaid = { ...paid }
|
||||||
|
if (nextParts.has(id)) { nextParts.delete(id); nextDirty.delete(id); delete nextPaid[id] }
|
||||||
|
else nextParts.add(id)
|
||||||
|
setParticipants(nextParts); setDirty(nextDirty)
|
||||||
|
setPaid(rebalance(nextPaid, nextDirty, nextParts, totalNum))
|
||||||
|
}
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
if (!valid) return
|
if (!valid) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
const payerList = Object.entries(payers).map(([uid, v]) => ({ user_id: Number(uid), amount: parseFloat(v) || 0 })).filter(p => p.amount > 0)
|
const payerList = [...participants]
|
||||||
|
.map(id => ({ user_id: id, amount: parseFloat(paid[id]) || 0 }))
|
||||||
|
.filter(p => p.amount > 0)
|
||||||
const data = {
|
const data = {
|
||||||
name: name.trim(), category: cat,
|
name: name.trim(), category: cat,
|
||||||
// Store the actual currency the amounts were entered in; conversion to the
|
// Store the actual currency the amounts were entered in; conversion to the
|
||||||
// viewer's display currency happens live (real rates), no manual rate.
|
// viewer's display currency happens live (real rates), no manual rate.
|
||||||
currency,
|
currency,
|
||||||
payers: payerList, member_ids: [...split],
|
payers: payerList, member_ids: [...participants],
|
||||||
expense_date: day || null,
|
expense_date: day || null,
|
||||||
|
// Always record the entered total: the server keeps it as-is for an unfinished
|
||||||
|
// expense (no payers) and otherwise re-derives it from the payer sum (== total).
|
||||||
|
total_price: totalNum,
|
||||||
|
// Link a freshly-created expense to its booking (create-from-booking flow).
|
||||||
|
...(!editing && prefill?.reservationId ? { reservation_id: prefill.reservationId } : {}),
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (editing) await updateBudgetItem(tripId, editing.id, data)
|
if (editing) await updateBudgetItem(tripId, editing.id, data)
|
||||||
@@ -728,7 +896,9 @@ function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: {
|
|||||||
<label className={labelCls}>{t('costs.totalAmount')}</label>
|
<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' }}>
|
<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>
|
<span className="text-content-faint" style={{ fontSize: 15 }}>{sym(currency)}</span>
|
||||||
<span className="text-content" style={{ flex: 1, fontSize: 15, fontWeight: 600, paddingLeft: 6 }}>{payersTotal.toFixed(2)}</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||||
@@ -744,11 +914,11 @@ function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{currency !== base && payersTotal > 0 && (
|
{currency !== base && totalNum > 0 && (
|
||||||
<div className="bg-surface-secondary border border-edge text-content-muted" style={{ borderRadius: 10, padding: '10px 12px', fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
<div className="bg-surface-secondary border border-edge text-content-muted" style={{ borderRadius: 10, padding: '10px 12px', fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
<span>{formatMoney(payersTotal, currency, locale)}</span>
|
<span>{formatMoney(totalNum, currency, locale)}</span>
|
||||||
<span className="text-content-faint">≈</span>
|
<span className="text-content-faint">≈</span>
|
||||||
<span className="text-content" style={{ fontWeight: 600 }}>{formatMoney(convert(payersTotal, currency), base, locale)}</span>
|
<span className="text-content" style={{ fontWeight: 600 }}>{formatMoney(convert(totalNum, currency), base, locale)}</span>
|
||||||
<span className="text-content-faint">· {t('costs.liveRate')}</span>
|
<span className="text-content-faint">· {t('costs.liveRate')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -773,39 +943,37 @@ function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: {
|
|||||||
<div>
|
<div>
|
||||||
<label className={labelCls}>{t('costs.whoPaid')}</label>
|
<label className={labelCls}>{t('costs.whoPaid')}</label>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||||
{people.map(p => (
|
{people.map((p, idx) => {
|
||||||
<div key={p.id} className="bg-surface-secondary border border-edge" style={{ display: 'grid', gridTemplateColumns: '1fr 130px', gap: 10, alignItems: 'center', padding: '8px 11px', borderRadius: 10 }}>
|
const on = participants.has(p.id)
|
||||||
<span className="text-content" style={{ fontSize: 14, fontWeight: 500 }}>{p.id === me ? t('costs.you') : p.username}</span>
|
|
||||||
<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="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={payers[p.id] || ''}
|
|
||||||
onChange={e => setPayers(prev => ({ ...prev, [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>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className={labelCls}>{t('costs.splitBetween')}</label>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 7 }}>
|
|
||||||
{people.map(p => {
|
|
||||||
const on = split.has(p.id)
|
|
||||||
return (
|
return (
|
||||||
<button key={p.id} onClick={() => setSplit(prev => { const n = new Set(prev); n.has(p.id) ? n.delete(p.id) : n.add(p.id); return n })}
|
<div key={p.id} className="bg-surface-secondary border border-edge" style={{ display: 'grid', gridTemplateColumns: '1fr 130px', gap: 10, alignItems: 'center', padding: '8px 11px', borderRadius: 10, opacity: on ? 1 : 0.5 }}>
|
||||||
className={on ? 'bg-surface-card text-content border' : 'bg-surface-secondary text-content-faint border border-edge'}
|
<button onClick={() => toggleParticipant(p.id)} style={{ display: 'inline-flex', alignItems: 'center', gap: 8, background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', padding: 0, minWidth: 0, textAlign: 'left' }}>
|
||||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '6px 13px 6px 7px', borderRadius: 999, fontSize: 13, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', borderColor: on ? 'var(--text-primary)' : undefined }}>
|
{p.avatar_url
|
||||||
{p.avatar_url
|
? <img src={p.avatar_url} alt="" style={{ width: 22, height: 22, borderRadius: '50%', objectFit: 'cover', display: 'block', flexShrink: 0, opacity: on ? 1 : 0.45 }} />
|
||||||
? <img src={p.avatar_url} alt="" style={{ width: 22, height: 22, borderRadius: '50%', objectFit: 'cover', display: 'block', opacity: on ? 1 : 0.45 }} />
|
: <span style={{ width: 22, height: 22, borderRadius: '50%', background: SPLIT_COLORS[idx % SPLIT_COLORS.length].gradient, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700, flexShrink: 0, opacity: on ? 1 : 0.45 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>}
|
||||||
: <span style={{ width: 22, height: 22, borderRadius: '50%', background: SPLIT_COLORS[people.findIndex(x => x.id === p.id) % SPLIT_COLORS.length].gradient, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700, opacity: on ? 1 : 0.45 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>}
|
<span className="text-content" style={{ fontSize: 14, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.id === me ? t('costs.you') : p.username}</span>
|
||||||
{p.id === me ? t('costs.you') : p.username}
|
</button>
|
||||||
</button>
|
{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="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>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => toggleParticipant(p.id)} className="text-content-faint" style={{ background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', fontSize: 12, textAlign: 'right' }}>{t('costs.tapToInclude')}</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-content-faint" style={{ marginTop: 10, fontSize: 12.5 }}>
|
<div style={{ marginTop: 10, fontSize: 12.5, display: 'flex', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
|
||||||
{split.size === 0 ? t('costs.pickSomeone') : t('costs.splitSummary', { count: split.size, amount: sym(currency) + each.toFixed(2) })}
|
<span className="text-content-faint">
|
||||||
|
{participants.size === 0 ? t('costs.pickSomeone') : t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })}
|
||||||
|
</span>
|
||||||
|
{paidEntered
|
||||||
|
? <span style={{ fontWeight: 600, color: balanced ? '#16a34a' : '#dc2626' }}>{sym(currency)}{paidSum.toFixed(2)} / {sym(currency)}{totalNum.toFixed(2)}</span>
|
||||||
|
: (totalNum > 0 && <span style={{ color: '#d97706', fontWeight: 600 }}>{t('costs.unfinishedHint')}</span>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,8 +32,32 @@ export const COST_CAT_META: Record<CostCategory, CostCategoryMeta> = {
|
|||||||
|
|
||||||
export const COST_CATEGORY_LIST: CostCategoryMeta[] = COST_CATEGORIES.map(k => COST_CAT_META[k])
|
export const COST_CATEGORY_LIST: CostCategoryMeta[] = COST_CATEGORIES.map(k => COST_CAT_META[k])
|
||||||
|
|
||||||
/** Map any stored category (incl. legacy free-text values) to a known meta. */
|
/**
|
||||||
export function catMeta(cat: string | null | undefined): CostCategoryMeta {
|
* Legacy / English free-text categories (and reservation type labels) mapped to
|
||||||
if (cat && cat in COST_CAT_META) return COST_CAT_META[cat as CostCategory]
|
* the fixed keys. Bookings used to store labels like "Flight"/"Train"/"Other",
|
||||||
return COST_CAT_META.other
|
* which never matched the lowercase keys and fell through to `other`.
|
||||||
|
*/
|
||||||
|
const LEGACY_CATEGORY_MAP: Record<string, CostCategory> = {
|
||||||
|
flight: 'flights', flights: 'flights', plane: 'flights', flug: 'flights',
|
||||||
|
train: 'transport', bus: 'transport', car: 'transport', 'car rental': 'transport',
|
||||||
|
ferry: 'transport', boat: 'transport', taxi: 'transport', transfer: 'transport',
|
||||||
|
transport: 'transport', transportation: 'transport',
|
||||||
|
hotel: 'accommodation', accommodation: 'accommodation', lodging: 'accommodation', hostel: 'accommodation',
|
||||||
|
restaurant: 'food', food: 'food', dining: 'food', meal: 'food', meals: 'food',
|
||||||
|
grocery: 'groceries', groceries: 'groceries',
|
||||||
|
activity: 'activities', activities: 'activities',
|
||||||
|
sightseeing: 'sightseeing', sights: 'sightseeing',
|
||||||
|
shop: 'shopping', shopping: 'shopping',
|
||||||
|
fee: 'fees', fees: 'fees',
|
||||||
|
health: 'health', medical: 'health',
|
||||||
|
tip: 'tips', tips: 'tips',
|
||||||
|
other: 'other', misc: 'other',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map any stored category (incl. legacy/localized free-text values) to a known meta. */
|
||||||
|
export function catMeta(cat: string | null | undefined): CostCategoryMeta {
|
||||||
|
if (!cat) return COST_CAT_META.other
|
||||||
|
if (cat in COST_CAT_META) return COST_CAT_META[cat as CostCategory]
|
||||||
|
const mapped = LEGACY_CATEGORY_MAP[cat.trim().toLowerCase()]
|
||||||
|
return mapped ? COST_CAT_META[mapped] : COST_CAT_META.other
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,22 @@ const transportReservation = {
|
|||||||
metadata: JSON.stringify({ airline: 'Air Italia', flight_number: 'AI123', departure_airport: 'CDG', arrival_airport: 'FCO' }),
|
metadata: JSON.stringify({ airline: 'Air Italia', flight_number: 'AI123', departure_airport: 'CDG', arrival_airport: 'FCO' }),
|
||||||
} as any
|
} as any
|
||||||
|
|
||||||
|
const multiLegFlight = {
|
||||||
|
id: 401,
|
||||||
|
title: 'Flight to Tokyo',
|
||||||
|
type: 'flight',
|
||||||
|
day_id: 10,
|
||||||
|
reservation_time: '2025-06-01T08:00:00',
|
||||||
|
confirmation_number: 'XYZ789',
|
||||||
|
metadata: JSON.stringify({
|
||||||
|
legs: [
|
||||||
|
{ from: 'FRA', to: 'BER', airline: 'Lufthansa', flight_number: 'LH1' },
|
||||||
|
{ from: 'BER', to: 'HND', airline: 'Lufthansa', flight_number: 'LH2' },
|
||||||
|
],
|
||||||
|
departure_airport: 'FRA', arrival_airport: 'HND', airline: 'Lufthansa', flight_number: 'LH1',
|
||||||
|
}),
|
||||||
|
} as any
|
||||||
|
|
||||||
const richArgs = {
|
const richArgs = {
|
||||||
trip: { id: 10, title: 'Italy Trip', description: 'Summer adventure', cover_image: '/uploads/cover.jpg' } as any,
|
trip: { id: 10, title: 'Italy Trip', description: 'Summer adventure', cover_image: '/uploads/cover.jpg' } as any,
|
||||||
days: [dayWithPlaces],
|
days: [dayWithPlaces],
|
||||||
@@ -196,6 +212,16 @@ describe('downloadTripPDF', () => {
|
|||||||
const iframe = getIframe()
|
const iframe = getIframe()
|
||||||
expect(iframe!.srcdoc).toContain('Flight to Rome')
|
expect(iframe!.srcdoc).toContain('Flight to Rome')
|
||||||
expect(iframe!.srcdoc).toContain('ABC123')
|
expect(iframe!.srcdoc).toContain('ABC123')
|
||||||
|
// Single-leg flight keeps its full-route subtitle.
|
||||||
|
expect(iframe!.srcdoc).toContain('Air Italia · AI123 · CDG → FCO')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-TRIPPDF-013b: renders every flight number for a multi-leg flight', async () => {
|
||||||
|
await downloadTripPDF({ ...richArgs, reservations: [multiLegFlight] })
|
||||||
|
const iframe = getIframe()
|
||||||
|
// One subtitle line per leg, each with its own flight number and segment route.
|
||||||
|
expect(iframe!.srcdoc).toContain('Lufthansa · LH1 · FRA → BER')
|
||||||
|
expect(iframe!.srcdoc).toContain('Lufthansa · LH2 · BER → HND')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FE-COMP-TRIPPDF-014: renders cover image when trip has cover_image', async () => {
|
it('FE-COMP-TRIPPDF-014: renders cover image when trip has cover_image', async () => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { accommodationsApi, mapsApi } from '../../api/client'
|
|||||||
import type { Trip, Day, Place, Category, AssignmentsMap, DayNote } from '../../types'
|
import type { Trip, Day, Place, Category, AssignmentsMap, DayNote } from '../../types'
|
||||||
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
|
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
|
||||||
import { splitReservationDateTime } from '../../utils/formatters'
|
import { splitReservationDateTime } from '../../utils/formatters'
|
||||||
|
import { getFlightLegs } from '../../utils/flightLegs'
|
||||||
|
|
||||||
function renderLucideIcon(icon:LucideIcon, props = {}) {
|
function renderLucideIcon(icon:LucideIcon, props = {}) {
|
||||||
if (!_renderToStaticMarkup) return ''
|
if (!_renderToStaticMarkup) return ''
|
||||||
@@ -215,17 +216,30 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
const icon = reservationIconSvg(r.type)
|
const icon = reservationIconSvg(r.type)
|
||||||
const color = RESERVATION_COLOR_MAP[r.type] || '#3b82f6'
|
const color = RESERVATION_COLOR_MAP[r.type] || '#3b82f6'
|
||||||
let subtitle = ''
|
let subtitle = ''
|
||||||
|
// Flights render one subtitle line per leg (see below); everything else is a single line.
|
||||||
|
let subtitleLines: string[] = []
|
||||||
if (r.type === 'flight') {
|
if (r.type === 'flight') {
|
||||||
// Full route over all waypoints (FRA → BER → HND), falling back to the
|
const legs = getFlightLegs(r)
|
||||||
// flat metadata pair for legacy single-leg flights without endpoints.
|
if (legs.length > 1) {
|
||||||
const stops = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)).map(e => e.code || e.name)
|
// Multi-leg: one line per leg so every flight number + segment route is shown.
|
||||||
const route = stops.length >= 2 ? stops.join(' → ') : (meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : '')
|
subtitleLines = legs.map(l =>
|
||||||
subtitle = [meta.airline, meta.flight_number, route].filter(Boolean).join(' · ')
|
[l.airline, l.flight_number,
|
||||||
|
(l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '']
|
||||||
|
.filter(Boolean).join(' · '))
|
||||||
|
.filter(Boolean)
|
||||||
|
} else {
|
||||||
|
// Single-leg: full route over all waypoints (FRA → BER → HND), falling back to the
|
||||||
|
// flat metadata pair for legacy single-leg flights without endpoints.
|
||||||
|
const stops = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)).map(e => e.code || e.name)
|
||||||
|
const route = stops.length >= 2 ? stops.join(' → ') : (meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : '')
|
||||||
|
subtitle = [meta.airline, meta.flight_number, route].filter(Boolean).join(' · ')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else if (r.type === 'train') subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : ''].filter(Boolean).join(' · ')
|
else if (r.type === 'train') subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : ''].filter(Boolean).join(' · ')
|
||||||
else if (r.type === 'restaurant') subtitle = [meta.party_size ? `${meta.party_size} guests` : ''].filter(Boolean).join(' · ')
|
else if (r.type === 'restaurant') subtitle = [meta.party_size ? `${meta.party_size} guests` : ''].filter(Boolean).join(' · ')
|
||||||
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
|
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
|
||||||
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
|
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
|
||||||
|
if (subtitleLines.length === 0 && subtitle) subtitleLines = [subtitle]
|
||||||
const locationLine = r.location || meta.location || ''
|
const locationLine = r.location || meta.location || ''
|
||||||
const phase = pdfGetSpanPhase(r, day.id)
|
const phase = pdfGetSpanPhase(r, day.id)
|
||||||
const spanLabel = pdfGetSpanLabel(r, phase)
|
const spanLabel = pdfGetSpanLabel(r, phase)
|
||||||
@@ -238,7 +252,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
<span class="note-icon">${icon}</span>
|
<span class="note-icon">${icon}</span>
|
||||||
<div class="note-body">
|
<div class="note-body">
|
||||||
<div class="note-text" style="font-weight: 600;">${titleHtml}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
|
<div class="note-text" style="font-weight: 600;">${titleHtml}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
|
||||||
${subtitle ? `<div class="note-time">${escHtml(subtitle)}</div>` : ''}
|
${subtitleLines.filter(Boolean).map(s => `<div class="note-time">${escHtml(s)}</div>`).join('')}
|
||||||
${locationLine ? `<div class="note-time">${escHtml(locationLine)}</div>` : ''}
|
${locationLine ? `<div class="note-time">${escHtml(locationLine)}</div>` : ''}
|
||||||
${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''}
|
${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { Plus, Pencil, Trash2 } from 'lucide-react'
|
||||||
|
import { useTripStore } from '../../store/tripStore'
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { formatMoney } from '../../utils/formatters'
|
||||||
|
import { catMeta } from '../Budget/costsCategories'
|
||||||
|
import type { BudgetItem } from '../../types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Costs block inside a booking modal. Replaces the old inline price + budget
|
||||||
|
* category fields: when no expense is linked yet it offers a "create expense"
|
||||||
|
* button (the modal saves the booking first, then opens the full Costs editor);
|
||||||
|
* once linked it shows the expense with edit / remove actions.
|
||||||
|
*/
|
||||||
|
export function BookingCostsSection({ reservationId, onCreate, onEdit, onRemove }: {
|
||||||
|
reservationId: number | null
|
||||||
|
onCreate: () => void
|
||||||
|
onEdit: (item: BudgetItem) => void
|
||||||
|
onRemove: (item: BudgetItem) => void
|
||||||
|
}) {
|
||||||
|
const { t, locale } = useTranslation()
|
||||||
|
const budgetItems = useTripStore(s => s.budgetItems)
|
||||||
|
const trip = useTripStore(s => s.trip)
|
||||||
|
const displayCurrency = useSettingsStore(s => s.settings.default_currency)
|
||||||
|
const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
|
||||||
|
const linked = reservationId ? budgetItems.find(i => i.reservation_id === reservationId) : null
|
||||||
|
|
||||||
|
const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]'
|
||||||
|
|
||||||
|
if (linked) {
|
||||||
|
const meta = catMeta(linked.category)
|
||||||
|
const Icon = meta.Icon
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>{t('reservations.linkedExpense')}</label>
|
||||||
|
<div className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', borderRadius: 10 }}>
|
||||||
|
<span style={{ width: 26, height: 26, borderRadius: 7, display: 'grid', placeItems: 'center', background: meta.color + '22', color: meta.color, flexShrink: 0 }}><Icon size={14} /></span>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div className="text-content" style={{ fontSize: 14, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{linked.name}</div>
|
||||||
|
<div className="text-content-faint" style={{ fontSize: 12 }}>{t(meta.labelKey)}</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-content" style={{ fontSize: 14, fontWeight: 700, flexShrink: 0 }}>{formatMoney(linked.total_price, linked.currency || base, locale)}</span>
|
||||||
|
<button type="button" onClick={() => onEdit(linked)} title={t('common.edit')} className="text-content-muted border border-edge bg-surface-card" style={{ display: 'inline-flex', padding: 7, borderRadius: 8, cursor: 'pointer' }}><Pencil size={13} /></button>
|
||||||
|
<button type="button" onClick={() => onRemove(linked)} title={t('reservations.removeExpense')} className="text-content-muted border border-edge bg-surface-card" style={{ display: 'inline-flex', padding: 7, borderRadius: 8, cursor: 'pointer' }}><Trash2 size={13} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>{t('reservations.costsLabel')}</label>
|
||||||
|
<button type="button" onClick={onCreate}
|
||||||
|
className="bg-surface-secondary border border-edge text-content"
|
||||||
|
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, padding: '11px 13px', borderRadius: 10, fontSize: 13.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||||
|
<Plus size={15} /> {t('reservations.createExpense')}
|
||||||
|
</button>
|
||||||
|
<div className="text-content-faint" style={{ fontSize: 11, marginTop: 6 }}>{t('reservations.createExpenseHint')}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { BudgetItem } from '../../types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A request from a booking modal to open the Costs expense editor — either to
|
||||||
|
* edit the already-linked expense, or to create a new one prefilled from the
|
||||||
|
* booking (the modal saves the booking first so `reservationId` is known).
|
||||||
|
*/
|
||||||
|
export interface BookingExpenseRequest {
|
||||||
|
editItem?: BudgetItem
|
||||||
|
prefill?: { reservationId?: number; name?: string; category?: string; amount?: number }
|
||||||
|
}
|
||||||
@@ -399,17 +399,38 @@ describe('PlaceFormModal', () => {
|
|||||||
expect(screen.queryByTestId('time-picker')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('time-picker')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-PLANNER-PLACEFORM-026: time section IS shown in edit mode', () => {
|
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)
|
||||||
const place = buildPlace({ name: 'Test' });
|
const place = buildPlace({ name: 'Test' });
|
||||||
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={null} />);
|
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={null} />);
|
||||||
// Time pickers are rendered when editing
|
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]} />);
|
||||||
expect(screen.getAllByTestId('time-picker').length).toBeGreaterThanOrEqual(2);
|
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', () => {
|
it('FE-PLANNER-PLACEFORM-027: end-before-start error disables submit', () => {
|
||||||
// Build a place with end_time before place_time
|
// Build an assignment whose place has end_time before place_time
|
||||||
const place = buildPlace({ name: 'Test', place_time: '14:00', end_time: '13:00' });
|
const place = buildPlace({ name: 'Test', place_time: '14:00', end_time: '13:00' });
|
||||||
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={null} />);
|
const assignment = buildAssignment({ id: 11, day_id: 5, place });
|
||||||
|
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={11} dayAssignments={[assignment]} />);
|
||||||
|
|
||||||
// hasTimeError = true → submit button disabled
|
// hasTimeError = true → submit button disabled
|
||||||
const submitBtn = screen.getByRole('button', { name: /^Update$/i });
|
const submitBtn = screen.getByRole('button', { name: /^Update$/i });
|
||||||
|
|||||||
@@ -92,6 +92,11 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (place) {
|
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({
|
setForm({
|
||||||
name: place.name || '',
|
name: place.name || '',
|
||||||
description: place.description || '',
|
description: place.description || '',
|
||||||
@@ -99,8 +104,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
lat: place.lat != null ? String(place.lat) : '',
|
lat: place.lat != null ? String(place.lat) : '',
|
||||||
lng: place.lng != null ? String(place.lng) : '',
|
lng: place.lng != null ? String(place.lng) : '',
|
||||||
category_id: place.category_id != null ? String(place.category_id) : '',
|
category_id: place.category_id != null ? String(place.category_id) : '',
|
||||||
place_time: place.place_time || '',
|
place_time: timeSource.place_time || '',
|
||||||
end_time: place.end_time || '',
|
end_time: timeSource.end_time || '',
|
||||||
notes: place.notes || '',
|
notes: place.notes || '',
|
||||||
transport_mode: place.transport_mode || 'walking',
|
transport_mode: place.transport_mode || 'walking',
|
||||||
website: place.website || '',
|
website: place.website || '',
|
||||||
@@ -121,7 +126,10 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
}
|
}
|
||||||
setPendingFiles([])
|
setPendingFiles([])
|
||||||
setDuplicateWarning(null)
|
setDuplicateWarning(null)
|
||||||
}, [place, prefillCoords, isOpen])
|
// 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])
|
||||||
|
|
||||||
// Derive location bias bounding box from the trip's existing places
|
// Derive location bias bounding box from the trip's existing places
|
||||||
const places = useTripStore((s) => s.places)
|
const places = useTripStore((s) => s.places)
|
||||||
@@ -728,8 +736,11 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Time — only shown when editing, not when creating */}
|
{/* Time is per day-assignment: only shown when a single assignment is in
|
||||||
{place && (
|
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 && (
|
||||||
<TimeSection
|
<TimeSection
|
||||||
form={form}
|
form={form}
|
||||||
handleChange={handleChange}
|
handleChange={handleChange}
|
||||||
|
|||||||
@@ -343,56 +343,51 @@ describe('ReservationModal', () => {
|
|||||||
|
|
||||||
// ── Budget addon ─────────────────────────────────────────────────────────────
|
// ── Budget addon ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('FE-PLANNER-RESMODAL-024: budget section visible when budget addon is enabled', () => {
|
it('FE-PLANNER-RESMODAL-024: costs section (create expense) visible when budget addon is enabled', () => {
|
||||||
seedStore(useAddonStore, {
|
seedStore(useAddonStore, {
|
||||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||||
loaded: true,
|
loaded: true,
|
||||||
});
|
});
|
||||||
render(<ReservationModal {...defaultProps} />);
|
render(<ReservationModal {...defaultProps} />);
|
||||||
expect(screen.getByText(/^Price$/i)).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /Create expense/i })).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Budget category/i)).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-PLANNER-RESMODAL-025: budget price input accepts valid decimal', async () => {
|
it('FE-PLANNER-RESMODAL-025: create-expense saves the booking (no create_budget_entry) then opens the Costs editor', async () => {
|
||||||
seedStore(useAddonStore, {
|
seedStore(useAddonStore, {
|
||||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||||
loaded: true,
|
loaded: true,
|
||||||
});
|
});
|
||||||
render(<ReservationModal {...defaultProps} />);
|
const onSave = vi.fn().mockResolvedValue({ id: 55 });
|
||||||
const priceInput = screen.getByPlaceholderText('0.00');
|
const onOpenExpense = vi.fn();
|
||||||
await userEvent.type(priceInput, '99.99');
|
render(<ReservationModal {...defaultProps} onSave={onSave} onOpenExpense={onOpenExpense} />);
|
||||||
expect((priceInput as HTMLInputElement).value).toBe('99.99');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-PLANNER-RESMODAL-026: budget hint shown when price > 0', async () => {
|
|
||||||
seedStore(useAddonStore, {
|
|
||||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
|
||||||
loaded: true,
|
|
||||||
});
|
|
||||||
render(<ReservationModal {...defaultProps} />);
|
|
||||||
const priceInput = screen.getByPlaceholderText('0.00');
|
|
||||||
await userEvent.type(priceInput, '50');
|
|
||||||
expect(screen.getByText(/budget entry will be created/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-PLANNER-RESMODAL-027: budget fields included in onSave when price is set', async () => {
|
|
||||||
seedStore(useAddonStore, {
|
|
||||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
|
||||||
loaded: true,
|
|
||||||
});
|
|
||||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
||||||
render(<ReservationModal {...defaultProps} onSave={onSave} />);
|
|
||||||
|
|
||||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Hotel Paris');
|
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Hotel Paris');
|
||||||
await userEvent.type(screen.getByPlaceholderText('0.00'), '120');
|
await userEvent.click(screen.getByRole('button', { name: /Create expense/i }));
|
||||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
||||||
|
|
||||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||||
expect(onSave).toHaveBeenCalledWith(
|
expect(onSave).not.toHaveBeenCalledWith(expect.objectContaining({ create_budget_entry: expect.anything() }));
|
||||||
expect.objectContaining({ create_budget_entry: expect.objectContaining({ total_price: 120 }) })
|
await waitFor(() =>
|
||||||
|
expect(onOpenExpense).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ prefill: expect.objectContaining({ reservationId: 55 }) })
|
||||||
|
)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-RESMODAL-026: linked expense summary shown for a booking with a linked cost', () => {
|
||||||
|
seedStore(useAddonStore, {
|
||||||
|
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||||
|
loaded: true,
|
||||||
|
});
|
||||||
|
seedStore(useTripStore, {
|
||||||
|
trip: buildTrip({ id: 1 }),
|
||||||
|
budgetItems: [
|
||||||
|
{ id: 7, trip_id: 1, name: 'Hotel deposit', total_price: 120, currency: 'EUR', category: 'accommodation', reservation_id: 9, members: [], payers: [], persons: 1, expense_date: null, paid_by_user_id: null },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
render(<ReservationModal {...defaultProps} reservation={buildReservation({ id: 9, type: 'hotel', title: 'Hotel Paris' })} />);
|
||||||
|
expect(screen.getByText('Hotel deposit')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
// ── File upload ───────────────────────────────────────────────────────────────
|
// ── File upload ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('FE-PLANNER-RESMODAL-028: pending file added for new reservation on file input change', async () => {
|
it('FE-PLANNER-RESMODAL-028: pending file added for new reservation on file input change', async () => {
|
||||||
@@ -599,22 +594,6 @@ describe('ReservationModal', () => {
|
|||||||
expect(filePickerItem).toBeInTheDocument();
|
expect(filePickerItem).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-PLANNER-RESMODAL-044: budget category dropdown options include existing categories', () => {
|
|
||||||
seedStore(useAddonStore, {
|
|
||||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
|
||||||
loaded: true,
|
|
||||||
});
|
|
||||||
seedStore(useTripStore, {
|
|
||||||
trip: buildTrip({ id: 1 }),
|
|
||||||
budgetItems: [
|
|
||||||
{ id: 1, trip_id: 1, name: 'Flight ticket', total_price: 300, category: 'Transport', paid_by_user_id: null, persons: 1, members: [], expense_date: null },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
render(<ReservationModal {...defaultProps} />);
|
|
||||||
// Budget section is visible
|
|
||||||
expect(screen.getByText(/Budget category/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-PLANNER-RESMODAL-045: tour type shows time pickers', async () => {
|
it('FE-PLANNER-RESMODAL-045: tour type shows time pickers', async () => {
|
||||||
render(<ReservationModal {...defaultProps} />);
|
render(<ReservationModal {...defaultProps} />);
|
||||||
await userEvent.click(screen.getByRole('button', { name: /^Tour$/i }));
|
await userEvent.click(screen.getByRole('button', { name: /^Tour$/i }));
|
||||||
@@ -632,31 +611,6 @@ describe('ReservationModal', () => {
|
|||||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ type: 'other' })));
|
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ type: 'other' })));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-PLANNER-RESMODAL-047: clicking budget category select changes the value', async () => {
|
|
||||||
seedStore(useAddonStore, {
|
|
||||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
|
||||||
loaded: true,
|
|
||||||
});
|
|
||||||
seedStore(useTripStore, {
|
|
||||||
trip: buildTrip({ id: 1 }),
|
|
||||||
budgetItems: [
|
|
||||||
{ id: 1, trip_id: 1, name: 'Ticket', total_price: 100, category: 'Transport', paid_by_user_id: null, persons: 1, members: [], expense_date: null },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
render(<ReservationModal {...defaultProps} />);
|
|
||||||
|
|
||||||
// Open the budget category CustomSelect (shows placeholder "Auto (from booking type)")
|
|
||||||
const budgetCategoryBtn = screen.getByText(/Auto \(from booking type\)/i).closest('button')!;
|
|
||||||
await userEvent.click(budgetCategoryBtn);
|
|
||||||
|
|
||||||
// Click the "Transport" category option
|
|
||||||
await waitFor(() => expect(screen.getByText('Transport')).toBeInTheDocument());
|
|
||||||
await userEvent.click(screen.getByText('Transport'));
|
|
||||||
|
|
||||||
// The select should now show "Transport"
|
|
||||||
expect(screen.getByText('Transport')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-PLANNER-RESMODAL-048: clicking attach file button triggers file input', async () => {
|
it('FE-PLANNER-RESMODAL-048: clicking attach file button triggers file input', async () => {
|
||||||
render(<ReservationModal {...defaultProps} />);
|
render(<ReservationModal {...defaultProps} />);
|
||||||
const attachBtn = screen.getByRole('button', { name: /Attach file/i });
|
const attachBtn = screen.getByRole('button', { name: /Attach file/i });
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import { useTranslation } from '../../i18n'
|
|||||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||||
import { openFile } from '../../utils/fileDownload'
|
import { openFile } from '../../utils/fileDownload'
|
||||||
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation } from '../../types'
|
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation, BudgetItem } from '../../types'
|
||||||
|
import { BookingCostsSection } from './BookingCostsSection'
|
||||||
|
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
||||||
|
import { typeToCostCategory } from '@trek/shared'
|
||||||
|
|
||||||
const TYPE_OPTIONS = [
|
const TYPE_OPTIONS = [
|
||||||
{ value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel },
|
{ value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel },
|
||||||
@@ -60,9 +63,10 @@ interface ReservationModalProps {
|
|||||||
onFileDelete: (fileId: number) => Promise<void>
|
onFileDelete: (fileId: number) => Promise<void>
|
||||||
accommodations?: Accommodation[]
|
accommodations?: Accommodation[]
|
||||||
defaultAssignmentId?: number | null
|
defaultAssignmentId?: number | null
|
||||||
|
onOpenExpense?: (req: BookingExpenseRequest) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [], defaultAssignmentId = null }: ReservationModalProps) {
|
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [], defaultAssignmentId = null, onOpenExpense }: ReservationModalProps) {
|
||||||
const { id: tripId } = useParams<{ id: string }>()
|
const { id: tripId } = useParams<{ id: string }>()
|
||||||
const loadFiles = useTripStore(s => s.loadFiles)
|
const loadFiles = useTripStore(s => s.loadFiles)
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -70,18 +74,14 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
const fileInputRef = useRef(null)
|
const fileInputRef = useRef(null)
|
||||||
|
|
||||||
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
|
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
|
||||||
const budgetItems = useTripStore(s => s.budgetItems)
|
const deleteBudgetItem = useTripStore(s => s.deleteBudgetItem)
|
||||||
const budgetCategories = useMemo(() => {
|
// Set right before submit when the user clicked create/edit expense (see TransportModal).
|
||||||
const cats = new Set<string>()
|
const expenseIntentRef = useRef<{ editItem?: BudgetItem; create?: boolean } | null>(null)
|
||||||
budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
|
|
||||||
return Array.from(cats).sort()
|
|
||||||
}, [budgetItems])
|
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
title: '', type: 'other', status: 'pending',
|
title: '', type: 'other', status: 'pending',
|
||||||
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
|
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
|
||||||
notes: '', assignment_id: '' as string | number, accommodation_id: '' as string | number,
|
notes: '', assignment_id: '' as string | number, accommodation_id: '' as string | number,
|
||||||
price: '', budget_category: '',
|
|
||||||
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
|
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
|
||||||
hotel_place_id: '' as string | number, hotel_start_day: '' as string | number, hotel_end_day: '' as string | number,
|
hotel_place_id: '' as string | number, hotel_start_day: '' as string | number, hotel_end_day: '' as string | number,
|
||||||
})
|
})
|
||||||
@@ -127,15 +127,12 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
hotel_place_id: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.place_id || '' })(),
|
hotel_place_id: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.place_id || '' })(),
|
||||||
hotel_start_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.start_day_id || '' })(),
|
hotel_start_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.start_day_id || '' })(),
|
||||||
hotel_end_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.end_day_id || '' })(),
|
hotel_end_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.end_day_id || '' })(),
|
||||||
price: meta.price || '',
|
|
||||||
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
|
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
setForm({
|
setForm({
|
||||||
title: '', type: 'other', status: 'pending',
|
title: '', type: 'other', status: 'pending',
|
||||||
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
|
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
|
||||||
notes: '', assignment_id: defaultAssignmentId ?? '', accommodation_id: '',
|
notes: '', assignment_id: defaultAssignmentId ?? '', accommodation_id: '',
|
||||||
price: '', budget_category: '',
|
|
||||||
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
|
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
|
||||||
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '',
|
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '',
|
||||||
})
|
})
|
||||||
@@ -167,8 +164,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
return endFull <= startFull
|
return endFull <= startFull
|
||||||
})()
|
})()
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e?: { preventDefault?: () => void }) => {
|
||||||
e.preventDefault()
|
e?.preventDefault?.()
|
||||||
if (!form.title.trim()) return
|
if (!form.title.trim()) return
|
||||||
if (isEndBeforeStart) { toast.error(t('reservations.validation.endBeforeStart')); return }
|
if (isEndBeforeStart) { toast.error(t('reservations.validation.endBeforeStart')); return }
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
@@ -185,11 +182,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
} else if (form.reservation_end_time && form.reservation_time) {
|
} else if (form.reservation_end_time && form.reservation_time) {
|
||||||
combinedEndTime = `${form.reservation_time.split('T')[0]}T${form.reservation_end_time}`
|
combinedEndTime = `${form.reservation_time.split('T')[0]}T${form.reservation_end_time}`
|
||||||
}
|
}
|
||||||
if (isBudgetEnabled) {
|
|
||||||
if (form.price) metadata.price = form.price
|
|
||||||
if (form.budget_category) metadata.budget_category = form.budget_category
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveData: Record<string, any> & { title: string } = {
|
const saveData: Record<string, any> & { title: string } = {
|
||||||
title: form.title, type: form.type, status: form.status,
|
title: form.title, type: form.type, status: form.status,
|
||||||
reservation_time: form.type === 'hotel' ? null : (form.reservation_time || null),
|
reservation_time: form.type === 'hotel' ? null : (form.reservation_time || null),
|
||||||
@@ -202,11 +194,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
endpoints: [],
|
endpoints: [],
|
||||||
needs_review: false,
|
needs_review: false,
|
||||||
}
|
}
|
||||||
if (isBudgetEnabled) {
|
|
||||||
saveData.create_budget_entry = form.price && parseFloat(form.price) > 0
|
|
||||||
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
|
|
||||||
: { total_price: 0 }
|
|
||||||
}
|
|
||||||
if (form.type === 'hotel' && form.hotel_start_day && form.hotel_end_day) {
|
if (form.type === 'hotel' && form.hotel_start_day && form.hotel_end_day) {
|
||||||
saveData.create_accommodation = {
|
saveData.create_accommodation = {
|
||||||
place_id: form.hotel_place_id || null,
|
place_id: form.hotel_place_id || null,
|
||||||
@@ -228,11 +215,25 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
await onFileUpload(fd)
|
await onFileUpload(fd)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Open the Costs editor for the saved booking when the user asked to
|
||||||
|
// create/edit its linked expense (gated on saved?.id).
|
||||||
|
const intent = expenseIntentRef.current
|
||||||
|
expenseIntentRef.current = null
|
||||||
|
if (intent && onOpenExpense && saved?.id) {
|
||||||
|
if (intent.editItem) onOpenExpense({ editItem: intent.editItem })
|
||||||
|
else onOpenExpense({ prefill: { reservationId: saved.id, name: form.title, category: typeToCostCategory(form.type) } })
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCreateExpense = () => { expenseIntentRef.current = { create: true }; handleSubmit() }
|
||||||
|
const handleEditExpense = (item: BudgetItem) => { expenseIntentRef.current = { editItem: item }; handleSubmit() }
|
||||||
|
const handleRemoveExpense = async (item: BudgetItem) => {
|
||||||
|
try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) }
|
||||||
|
}
|
||||||
|
|
||||||
const handleFileChange = async (e) => {
|
const handleFileChange = async (e) => {
|
||||||
const file = (e.target as HTMLInputElement).files?.[0]
|
const file = (e.target as HTMLInputElement).files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
@@ -610,38 +611,14 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Price + Budget Category */}
|
{/* Costs — create / view the expense linked to this booking */}
|
||||||
{isBudgetEnabled && (
|
{isBudgetEnabled && (
|
||||||
<>
|
<BookingCostsSection
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
reservationId={reservation?.id ?? null}
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
onCreate={handleCreateExpense}
|
||||||
<label className={labelClass}>{t('reservations.price')}</label>
|
onEdit={handleEditExpense}
|
||||||
<input type="text" inputMode="decimal" value={form.price}
|
onRemove={handleRemoveExpense}
|
||||||
onChange={e => { const v = e.target.value; if (v === '' || /^\d*[.,]?\d{0,2}$/.test(v)) set('price', v.replace(',', '.')) }}
|
/>
|
||||||
onPaste={e => { e.preventDefault(); let txt = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = txt.lastIndexOf(','), ld = txt.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { txt = txt.substring(0, dp).replace(/[.,]/g, '') + '.' + txt.substring(dp + 1) } else { txt = txt.replace(/[.,]/g, '') } set('price', txt) }}
|
|
||||||
placeholder="0.00"
|
|
||||||
className={inputClass} />
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<label className={labelClass}>{t('reservations.budgetCategory')}</label>
|
|
||||||
<CustomSelect
|
|
||||||
value={form.budget_category}
|
|
||||||
onChange={v => set('budget_category', v)}
|
|
||||||
options={[
|
|
||||||
{ value: '', label: t('reservations.budgetCategoryAuto') },
|
|
||||||
...budgetCategories.map(c => ({ value: c, label: c })),
|
|
||||||
]}
|
|
||||||
placeholder={t('reservations.budgetCategoryAuto')}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{form.price && parseFloat(form.price) > 0 && (
|
|
||||||
<div className="text-content-faint" style={{ fontSize: 11, marginTop: -4 }}>
|
|
||||||
{t('reservations.budgetHint')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -132,34 +132,37 @@ describe('TransportModal', () => {
|
|||||||
|
|
||||||
// ── Budget addon ─────────────────────────────────────────────────────────────
|
// ── Budget addon ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('FE-PLANNER-TRANSMODAL-011: budget section visible when addon is enabled', () => {
|
it('FE-PLANNER-TRANSMODAL-011: costs section (create expense) visible when budget addon is enabled', () => {
|
||||||
seedStore(useAddonStore, {
|
seedStore(useAddonStore, {
|
||||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||||
loaded: true,
|
loaded: true,
|
||||||
});
|
});
|
||||||
render(<TransportModal {...defaultProps} />);
|
render(<TransportModal {...defaultProps} />);
|
||||||
expect(screen.getByText(/^Price$/i)).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /Create expense/i })).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Budget category/i)).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-PLANNER-TRANSMODAL-012: budget section not shown when addon is disabled', () => {
|
it('FE-PLANNER-TRANSMODAL-012: costs section not shown when budget addon is disabled', () => {
|
||||||
render(<TransportModal {...defaultProps} />);
|
render(<TransportModal {...defaultProps} />);
|
||||||
expect(screen.queryByPlaceholderText('0.00')).not.toBeInTheDocument();
|
expect(screen.queryByRole('button', { name: /Create expense/i })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-PLANNER-TRANSMODAL-013: budget fields included in onSave when price is set', async () => {
|
it('FE-PLANNER-TRANSMODAL-013: create-expense saves the booking (no create_budget_entry) then opens the Costs editor', async () => {
|
||||||
seedStore(useAddonStore, {
|
seedStore(useAddonStore, {
|
||||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||||
loaded: true,
|
loaded: true,
|
||||||
});
|
});
|
||||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
const onSave = vi.fn().mockResolvedValue({ id: 42 });
|
||||||
render(<TransportModal {...defaultProps} onSave={onSave} />);
|
const onOpenExpense = vi.fn();
|
||||||
|
render(<TransportModal {...defaultProps} onSave={onSave} onOpenExpense={onOpenExpense} />);
|
||||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'ICE Train');
|
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'ICE Train');
|
||||||
await userEvent.type(screen.getByPlaceholderText('0.00'), '85');
|
await userEvent.click(screen.getByRole('button', { name: /Create expense/i }));
|
||||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
||||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||||
expect(onSave).toHaveBeenCalledWith(
|
// The legacy auto-budget mechanism is gone; the expense is created via the editor instead.
|
||||||
expect.objectContaining({ create_budget_entry: expect.objectContaining({ total_price: 85 }) })
|
expect(onSave).not.toHaveBeenCalledWith(expect.objectContaining({ create_budget_entry: expect.anything() }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(onOpenExpense).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ prefill: expect.objectContaining({ reservationId: 42 }) })
|
||||||
|
)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useMemo, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, Paperclip, FileText, X, ExternalLink, Link2, Plus, Trash2 } from 'lucide-react'
|
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, Paperclip, FileText, X, ExternalLink, Link2, Plus, Trash2 } from 'lucide-react'
|
||||||
import Modal from '../shared/Modal'
|
import Modal from '../shared/Modal'
|
||||||
@@ -13,8 +13,11 @@ import { useAddonStore } from '../../store/addonStore'
|
|||||||
import { formatDate, splitReservationDateTime } from '../../utils/formatters'
|
import { formatDate, splitReservationDateTime } from '../../utils/formatters'
|
||||||
import { openFile } from '../../utils/fileDownload'
|
import { openFile } from '../../utils/fileDownload'
|
||||||
import apiClient from '../../api/client'
|
import apiClient from '../../api/client'
|
||||||
import type { Day, Reservation, ReservationEndpoint, TripFile } from '../../types'
|
import type { Day, Reservation, ReservationEndpoint, TripFile, BudgetItem } from '../../types'
|
||||||
import { parseReservationMetadata, orderedEndpoints } from '../../utils/flightLegs'
|
import { parseReservationMetadata, orderedEndpoints } from '../../utils/flightLegs'
|
||||||
|
import { BookingCostsSection } from './BookingCostsSection'
|
||||||
|
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
||||||
|
import { typeToCostCategory } from '@trek/shared'
|
||||||
|
|
||||||
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'] as const
|
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'] as const
|
||||||
type TransportType = typeof TRANSPORT_TYPES[number]
|
type TransportType = typeof TRANSPORT_TYPES[number]
|
||||||
@@ -105,8 +108,6 @@ const defaultForm = {
|
|||||||
arrival_time: '',
|
arrival_time: '',
|
||||||
confirmation_number: '',
|
confirmation_number: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
price: '',
|
|
||||||
budget_category: '',
|
|
||||||
meta_airline: '',
|
meta_airline: '',
|
||||||
meta_flight_number: '',
|
meta_flight_number: '',
|
||||||
meta_train_number: '',
|
meta_train_number: '',
|
||||||
@@ -124,20 +125,20 @@ interface TransportModalProps {
|
|||||||
files?: TripFile[]
|
files?: TripFile[]
|
||||||
onFileUpload?: (fd: FormData) => Promise<unknown>
|
onFileUpload?: (fd: FormData) => Promise<unknown>
|
||||||
onFileDelete?: (fileId: number) => Promise<void>
|
onFileDelete?: (fileId: number) => Promise<void>
|
||||||
|
onOpenExpense?: (req: BookingExpenseRequest) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete }: TransportModalProps) {
|
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete, onOpenExpense }: TransportModalProps) {
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
|
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
|
||||||
const budgetItems = useTripStore(s => s.budgetItems)
|
const budgetItems = useTripStore(s => s.budgetItems)
|
||||||
|
const deleteBudgetItem = useTripStore(s => s.deleteBudgetItem)
|
||||||
const loadFiles = useTripStore(s => s.loadFiles)
|
const loadFiles = useTripStore(s => s.loadFiles)
|
||||||
const budgetCategories = useMemo(() => {
|
|
||||||
const cats = new Set<string>()
|
|
||||||
budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
|
|
||||||
return Array.from(cats).sort()
|
|
||||||
}, [budgetItems])
|
|
||||||
const { id: tripId } = useParams<{ id: string }>()
|
const { id: tripId } = useParams<{ id: string }>()
|
||||||
|
// Set right before submitting when the user clicked "create/edit expense", so
|
||||||
|
// the post-save handler knows to open the Costs editor for the saved booking.
|
||||||
|
const expenseIntentRef = useRef<{ editItem?: BudgetItem; create?: boolean } | null>(null)
|
||||||
const [form, setForm] = useState({ ...defaultForm })
|
const [form, setForm] = useState({ ...defaultForm })
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [fromPick, setFromPick] = useState<EndpointPick>({})
|
const [fromPick, setFromPick] = useState<EndpointPick>({})
|
||||||
@@ -177,8 +178,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
meta_train_number: meta.train_number || '',
|
meta_train_number: meta.train_number || '',
|
||||||
meta_platform: meta.platform || '',
|
meta_platform: meta.platform || '',
|
||||||
meta_seat: meta.seat || '',
|
meta_seat: meta.seat || '',
|
||||||
price: meta.price || '',
|
|
||||||
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
|
|
||||||
})
|
})
|
||||||
if (type === 'flight') {
|
if (type === 'flight') {
|
||||||
const orderedEps = orderedEndpoints(reservation)
|
const orderedEps = orderedEndpoints(reservation)
|
||||||
@@ -229,8 +228,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
|
|
||||||
const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value }))
|
const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value }))
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e?: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e?.preventDefault()
|
||||||
if (!form.title.trim()) return
|
if (!form.title.trim()) return
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
try {
|
try {
|
||||||
@@ -289,11 +288,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
if (form.meta_platform) metadata.platform = form.meta_platform
|
if (form.meta_platform) metadata.platform = form.meta_platform
|
||||||
if (form.meta_seat) metadata.seat = form.meta_seat
|
if (form.meta_seat) metadata.seat = form.meta_seat
|
||||||
}
|
}
|
||||||
if (isBudgetEnabled) {
|
|
||||||
if (form.price) metadata.price = form.price
|
|
||||||
if (form.budget_category) metadata.budget_category = form.budget_category
|
|
||||||
}
|
|
||||||
|
|
||||||
const startDate = startDay?.date ?? null
|
const startDate = startDay?.date ?? null
|
||||||
const endDate = (endDay ?? startDay)?.date ?? null
|
const endDate = (endDay ?? startDay)?.date ?? null
|
||||||
const endpoints: ReturnType<typeof endpointFromAirport>[] = []
|
const endpoints: ReturnType<typeof endpointFromAirport>[] = []
|
||||||
@@ -334,11 +328,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
endpoints,
|
endpoints,
|
||||||
needs_review: false,
|
needs_review: false,
|
||||||
}
|
}
|
||||||
if (isBudgetEnabled) {
|
|
||||||
(payload as any).create_budget_entry = form.price && parseFloat(form.price) > 0
|
|
||||||
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
|
|
||||||
: { total_price: 0 }
|
|
||||||
}
|
|
||||||
const saved = await onSave(payload)
|
const saved = await onSave(payload)
|
||||||
if (!reservation?.id && saved?.id && pendingFiles.length > 0 && onFileUpload) {
|
if (!reservation?.id && saved?.id && pendingFiles.length > 0 && onFileUpload) {
|
||||||
for (const file of pendingFiles) {
|
for (const file of pendingFiles) {
|
||||||
@@ -349,6 +338,14 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
await onFileUpload(fd)
|
await onFileUpload(fd)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The user asked to create/edit the linked expense — open the Costs editor
|
||||||
|
// for the now-saved booking. Gated on saved?.id so a failed save doesn't.
|
||||||
|
const intent = expenseIntentRef.current
|
||||||
|
expenseIntentRef.current = null
|
||||||
|
if (intent && onOpenExpense && saved?.id) {
|
||||||
|
if (intent.editItem) onOpenExpense({ editItem: intent.editItem })
|
||||||
|
else onOpenExpense({ prefill: { reservationId: saved.id, name: form.title, category: typeToCostCategory(form.type) } })
|
||||||
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -356,6 +353,12 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCreateExpense = () => { expenseIntentRef.current = { create: true }; handleSubmit() }
|
||||||
|
const handleEditExpense = (item: BudgetItem) => { expenseIntentRef.current = { editItem: item }; handleSubmit() }
|
||||||
|
const handleRemoveExpense = async (item: BudgetItem) => {
|
||||||
|
try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) }
|
||||||
|
}
|
||||||
|
|
||||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
@@ -712,38 +715,14 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Price + Budget Category */}
|
{/* Costs — create / view the expense linked to this booking */}
|
||||||
{isBudgetEnabled && (
|
{isBudgetEnabled && (
|
||||||
<>
|
<BookingCostsSection
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
reservationId={reservation?.id ?? null}
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
onCreate={handleCreateExpense}
|
||||||
<label className={labelClass}>{t('reservations.price')}</label>
|
onEdit={handleEditExpense}
|
||||||
<input type="text" inputMode="decimal" value={form.price}
|
onRemove={handleRemoveExpense}
|
||||||
onChange={e => { const v = e.target.value; if (v === '' || /^\d*[.,]?\d{0,2}$/.test(v)) set('price', v.replace(',', '.')) }}
|
/>
|
||||||
onPaste={e => { e.preventDefault(); let txt = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = txt.lastIndexOf(','), ld = txt.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { txt = txt.substring(0, dp).replace(/[.,]/g, '') + '.' + txt.substring(dp + 1) } else { txt = txt.replace(/[.,]/g, '') } set('price', txt) }}
|
|
||||||
placeholder="0.00"
|
|
||||||
className={inputClass} />
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<label className={labelClass}>{t('reservations.budgetCategory')}</label>
|
|
||||||
<CustomSelect
|
|
||||||
value={form.budget_category}
|
|
||||||
onChange={v => set('budget_category', v)}
|
|
||||||
options={[
|
|
||||||
{ value: '', label: t('reservations.budgetCategoryAuto') },
|
|
||||||
...budgetCategories.map(c => ({ value: c, label: c })),
|
|
||||||
]}
|
|
||||||
placeholder={t('reservations.budgetCategoryAuto')}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{form.price && parseFloat(form.price) > 0 && (
|
|
||||||
<div className="text-content-faint" style={{ fontSize: 11, marginTop: -4 }}>
|
|
||||||
{t('reservations.budgetHint')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export default function AirTrailConnectionSection(): React.ReactElement {
|
|||||||
const [url, setUrl] = useState('')
|
const [url, setUrl] = useState('')
|
||||||
const [apiKey, setApiKey] = useState('')
|
const [apiKey, setApiKey] = useState('')
|
||||||
const [allowInsecureTls, setAllowInsecureTls] = useState(false)
|
const [allowInsecureTls, setAllowInsecureTls] = useState(false)
|
||||||
|
const [writeEnabled, setWriteEnabled] = useState(false)
|
||||||
const [connected, setConnected] = useState(false)
|
const [connected, setConnected] = useState(false)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
@@ -30,6 +31,7 @@ export default function AirTrailConnectionSection(): React.ReactElement {
|
|||||||
.then(d => {
|
.then(d => {
|
||||||
setUrl(d.url || '')
|
setUrl(d.url || '')
|
||||||
setAllowInsecureTls(!!d.allowInsecureTls)
|
setAllowInsecureTls(!!d.allowInsecureTls)
|
||||||
|
setWriteEnabled(!!d.writeEnabled)
|
||||||
setConnected(!!d.connected)
|
setConnected(!!d.connected)
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
@@ -46,7 +48,7 @@ export default function AirTrailConnectionSection(): React.ReactElement {
|
|||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
const d = await airtrailApi.saveSettings({ url: url.trim(), allowInsecureTls, ...keyPayload() })
|
const d = await airtrailApi.saveSettings({ url: url.trim(), allowInsecureTls, writeEnabled, ...keyPayload() })
|
||||||
const status = await airtrailApi.status().catch(() => ({ connected: false }))
|
const status = await airtrailApi.status().catch(() => ({ connected: false }))
|
||||||
setConnected(!!status.connected)
|
setConnected(!!status.connected)
|
||||||
setApiKey('')
|
setApiKey('')
|
||||||
@@ -107,6 +109,14 @@ export default function AirTrailConnectionSection(): React.ReactElement {
|
|||||||
<span className="text-sm font-medium text-slate-700">{t('settings.airtrail.allowInsecureTls')}</span>
|
<span className="text-sm font-medium text-slate-700">{t('settings.airtrail.allowInsecureTls')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ToggleSwitch on={writeEnabled} onToggle={() => setWriteEnabled(v => !v)} />
|
||||||
|
<span className="text-sm font-medium text-slate-700">{t('settings.airtrail.writeBack')}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">{t('settings.airtrail.writeBackHint')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export default function VacayCalendar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 pb-14">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3" style={{ paddingBottom: 'calc(var(--bottom-nav-h, 0px) + 80px)' }}>
|
||||||
{Array.from({ length: 12 }, (_, i) => (
|
{Array.from({ length: 12 }, (_, i) => (
|
||||||
<VacayMonthCard
|
<VacayMonthCard
|
||||||
key={i}
|
key={i}
|
||||||
@@ -89,8 +89,8 @@ export default function VacayCalendar() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Floating toolbar */}
|
{/* Floating toolbar — lift above the mobile bottom nav (z-60). On desktop --bottom-nav-h is 0px. */}
|
||||||
<div className="sticky bottom-3 sm:bottom-4 mt-3 sm:mt-4 flex items-center justify-center z-30 px-2">
|
<div className="sticky mt-3 sm:mt-4 flex items-center justify-center px-2" style={{ bottom: 'calc(var(--bottom-nav-h, 0px) + 12px)', zIndex: 61 }}>
|
||||||
<div className="flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-1.5 sm:py-2 rounded-xl border bg-surface-card border-edge" style={{ boxShadow: '0 8px 32px rgba(0,0,0,0.12)' }}>
|
<div className="flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-1.5 sm:py-2 rounded-xl border bg-surface-card border-edge" style={{ boxShadow: '0 8px 32px rgba(0,0,0,0.12)' }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCompanyMode(false)}
|
onClick={() => setCompanyMode(false)}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import CustomSelect from '../components/shared/CustomSelect'
|
|||||||
import { Globe, MapPin, Briefcase, Calendar, Flag, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2, Search } from 'lucide-react'
|
import { Globe, MapPin, Briefcase, Calendar, Flag, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2, Search } from 'lucide-react'
|
||||||
import type { TranslationFn } from '../types'
|
import type { TranslationFn } from '../types'
|
||||||
import { A2_TO_A3, countryCodeToFlag, type AtlasCountry, type AtlasStats, type AtlasData, type CountryDetail } from './atlas/atlasModel'
|
import { A2_TO_A3, countryCodeToFlag, type AtlasCountry, type AtlasStats, type AtlasData, type CountryDetail } from './atlas/atlasModel'
|
||||||
|
import { continentForCountry } from '@trek/shared'
|
||||||
import { useAtlas } from './atlas/useAtlas'
|
import { useAtlas } from './atlas/useAtlas'
|
||||||
import AtlasCountrySearch from './atlas/AtlasCountrySearch'
|
import AtlasCountrySearch from './atlas/AtlasCountrySearch'
|
||||||
import { useToast } from '../components/shared/Toast'
|
import { useToast } from '../components/shared/Toast'
|
||||||
@@ -212,7 +213,8 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
await apiClient.post(`/addons/atlas/country/${confirmAction.code}/mark`)
|
await apiClient.post(`/addons/atlas/country/${confirmAction.code}/mark`)
|
||||||
setData(prev => {
|
setData(prev => {
|
||||||
if (!prev || prev.countries.find(c => c.code === confirmAction.code)) return prev
|
if (!prev || prev.countries.find(c => c.code === confirmAction.code)) return prev
|
||||||
return { ...prev, countries: [...prev.countries, { code: confirmAction.code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 } }
|
const cont = continentForCountry(confirmAction.code)
|
||||||
|
return { ...prev, countries: [...prev.countries, { code: confirmAction.code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 }, continents: { ...prev.continents, [cont]: (prev.continents?.[cont] || 0) + 1 } }
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||||
@@ -260,7 +262,8 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
})
|
})
|
||||||
setData(prev => {
|
setData(prev => {
|
||||||
if (!prev || prev.countries.find(c => c.code === countryCode)) return prev
|
if (!prev || prev.countries.find(c => c.code === countryCode)) return prev
|
||||||
return { ...prev, countries: [...prev.countries, { code: countryCode, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 } }
|
const cont = continentForCountry(countryCode)
|
||||||
|
return { ...prev, countries: [...prev.countries, { code: countryCode, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 }, continents: { ...prev.continents, [cont]: (prev.continents?.[cont] || 0) + 1 } }
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||||
@@ -339,10 +342,12 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
if (!c || c.placeCount > 0 || c.tripCount > 0) return prev
|
if (!c || c.placeCount > 0 || c.tripCount > 0) return prev
|
||||||
const remainingRegions = (visitedRegions[countryCode] || []).filter(r => r.code !== rCode && r.manuallyMarked)
|
const remainingRegions = (visitedRegions[countryCode] || []).filter(r => r.code !== rCode && r.manuallyMarked)
|
||||||
if (remainingRegions.length > 0) return prev
|
if (remainingRegions.length > 0) return prev
|
||||||
|
const cont = continentForCountry(countryCode)
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
countries: prev.countries.filter(c => c.code !== countryCode),
|
countries: prev.countries.filter(c => c.code !== countryCode),
|
||||||
stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
|
stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
|
||||||
|
continents: { ...prev.continents, [cont]: Math.max(0, (prev.continents?.[cont] || 0) - 1) },
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
Plane, Hotel, Utensils, Clock, RefreshCw, ArrowRightLeft, Calendar,
|
Plane, Hotel, Utensils, Clock, RefreshCw, ArrowRightLeft, Calendar,
|
||||||
LayoutGrid, List, Ticket, X,
|
LayoutGrid, List, Ticket, X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { formatTime, splitReservationDateTime } from '../utils/formatters'
|
||||||
|
import { useSettingsStore } from '../store/settingsStore'
|
||||||
import '../styles/dashboard.css'
|
import '../styles/dashboard.css'
|
||||||
|
|
||||||
const GRADIENTS = [
|
const GRADIENTS = [
|
||||||
@@ -36,6 +38,7 @@ function tripGradient(id: number): string { return GRADIENTS[id % GRADIENTS.leng
|
|||||||
function splitDate(dateStr: string | null | undefined, locale: string): { d: string; m: string } | null {
|
function splitDate(dateStr: string | null | undefined, locale: string): { d: string; m: string } | null {
|
||||||
if (!dateStr) return null
|
if (!dateStr) return null
|
||||||
const date = new Date(dateStr + 'T00:00:00Z')
|
const date = new Date(dateStr + 'T00:00:00Z')
|
||||||
|
if (isNaN(date.getTime())) return null // malformed date — render a dash, never crash
|
||||||
return {
|
return {
|
||||||
d: date.toLocaleDateString(locale, { day: 'numeric', timeZone: 'UTC' }),
|
d: date.toLocaleDateString(locale, { day: 'numeric', timeZone: 'UTC' }),
|
||||||
m: date.toLocaleDateString(locale, { month: 'short', timeZone: 'UTC' }),
|
m: date.toLocaleDateString(locale, { month: 'short', timeZone: 'UTC' }),
|
||||||
@@ -602,6 +605,7 @@ function UpcomingTool({ items, locale, onOpen }: {
|
|||||||
items: UpcomingReservation[]; locale: string; onOpen: (tripId: number) => void
|
items: UpcomingReservation[]; locale: string; onOpen: (tripId: number) => void
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const timeFormat = useSettingsStore(s => s.settings.time_format)
|
||||||
return (
|
return (
|
||||||
<div className="tool">
|
<div className="tool">
|
||||||
<div className="tool-head">
|
<div className="tool-head">
|
||||||
@@ -612,10 +616,13 @@ function UpcomingTool({ items, locale, onOpen }: {
|
|||||||
) : (
|
) : (
|
||||||
<div className="upc-list">
|
<div className="upc-list">
|
||||||
{items.map(r => {
|
{items.map(r => {
|
||||||
const when = r.reservation_time || (r.day_date ? r.day_date + 'T00:00:00' : null)
|
// Read the date/time straight from the stored string parts. Going through
|
||||||
const d = when ? new Date(when) : null
|
// new Date(...).toISOString() reinterprets the naive local time as UTC and
|
||||||
const dateStr = d ? splitDate(d.toISOString().slice(0, 10), locale) : null
|
// can roll the displayed day forward/back in non-UTC timezones.
|
||||||
const timeStr = r.reservation_time ? new Date(r.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }) : null
|
const parsed = splitReservationDateTime(r.reservation_time)
|
||||||
|
const datePart = parsed.date || r.day_date || null
|
||||||
|
const dateStr = datePart ? splitDate(datePart, locale) : null
|
||||||
|
const timeStr = parsed.time ? formatTime(parsed.time, locale, timeFormat) : null
|
||||||
const typeClass = RES_TYPE_CLASS[r.type] || 'other'
|
const typeClass = RES_TYPE_CLASS[r.type] || 'other'
|
||||||
return (
|
return (
|
||||||
<div className="upc-item" key={r.id} onClick={() => onOpen(r.trip_id)}>
|
<div className="upc-item" key={r.id} onClick={() => onOpen(r.trip_id)}>
|
||||||
|
|||||||
@@ -405,4 +405,79 @@ describe('SharedTripPage', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('FE-PAGE-SHARED-017: Multi-leg flight shows each leg in the Day Plan', () => {
|
||||||
|
const day = { id: 101, trip_id: 1, day_number: 1, date: '2026-07-01', title: 'Day One', notes: null };
|
||||||
|
const multiLegFlight = {
|
||||||
|
id: 9, trip_id: 1, title: 'Flight', type: 'flight', status: 'confirmed',
|
||||||
|
day_id: 101, end_day_id: 101,
|
||||||
|
reservation_time: '2026-07-01T08:00:00', reservation_end_time: '2026-07-01T20:00:00',
|
||||||
|
metadata: JSON.stringify({
|
||||||
|
legs: [
|
||||||
|
{ from: 'FRA', to: 'BER', airline: 'Lufthansa', flight_number: 'LH1', dep_day_id: 101, dep_time: '08:00', arr_day_id: 101, arr_time: '09:00' },
|
||||||
|
{ from: 'BER', to: 'HND', airline: 'Lufthansa', flight_number: 'LH2', dep_day_id: 101, dep_time: '10:00', arr_day_id: 101, arr_time: '20:00' },
|
||||||
|
],
|
||||||
|
departure_airport: 'FRA', arrival_airport: 'HND', airline: 'Lufthansa', flight_number: 'LH1',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
function serveMultiLeg(token: string) {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/shared/:token', ({ params }) => {
|
||||||
|
if (params.token !== token) return;
|
||||||
|
return HttpResponse.json({
|
||||||
|
trip: { id: 1, title: 'Shared Paris Trip', start_date: '2026-07-01', end_date: '2026-07-05' },
|
||||||
|
days: [day],
|
||||||
|
assignments: {},
|
||||||
|
dayNotes: {},
|
||||||
|
places: [],
|
||||||
|
reservations: [multiLegFlight],
|
||||||
|
accommodations: [],
|
||||||
|
packing: [],
|
||||||
|
budget: [],
|
||||||
|
categories: [],
|
||||||
|
permissions: { share_bookings: true, share_packing: false, share_budget: false, share_collab: false },
|
||||||
|
collab: [],
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders each leg with its own route, not the overall start/end', async () => {
|
||||||
|
serveMultiLeg('multileg-token');
|
||||||
|
renderSharedTrip('multileg-token');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expand the day to reveal the timeline
|
||||||
|
fireEvent.click(screen.getByText('Day One'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/FRA → BER/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
// Second leg shows its OWN route + flight number (the bug showed the overall route here)
|
||||||
|
expect(screen.getByText(/BER → HND/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/LH2/)).toBeInTheDocument();
|
||||||
|
// The overall start→end must NOT appear on any leg
|
||||||
|
expect(screen.queryByText(/FRA → HND/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists each leg flight number in the Bookings tab', async () => {
|
||||||
|
serveMultiLeg('multileg-bookings-token');
|
||||||
|
renderSharedTrip('multileg-bookings-token');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /bookings/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/LH1/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText(/LH2/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { renderToStaticMarkup } from 'react-dom/server'
|
|||||||
import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react'
|
import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react'
|
||||||
import { isDayInAccommodationRange } from '../utils/dayOrder'
|
import { isDayInAccommodationRange } from '../utils/dayOrder'
|
||||||
import { getTransportForDay, getMergedItems } from '../utils/dayMerge'
|
import { getTransportForDay, getMergedItems } from '../utils/dayMerge'
|
||||||
|
import { getFlightLegs } from '../utils/flightLegs'
|
||||||
import { splitReservationDateTime } from '../utils/formatters'
|
import { splitReservationDateTime } from '../utils/formatters'
|
||||||
|
|
||||||
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
|
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
|
||||||
@@ -214,16 +215,24 @@ export default function SharedTripPage() {
|
|||||||
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
|
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
|
||||||
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
||||||
const time = splitReservationDateTime(r.reservation_time).time ?? ''
|
const time = splitReservationDateTime(r.reservation_time).time ?? ''
|
||||||
|
const endTime = splitReservationDateTime(r.reservation_end_time).time ?? ''
|
||||||
let sub = ''
|
let sub = ''
|
||||||
if (r.type === 'flight') sub = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
|
if (r.type === 'flight') {
|
||||||
|
if (r.__leg) {
|
||||||
|
// One leg of a multi-leg flight — show this segment's own route/flight number.
|
||||||
|
sub = [r.__leg.airline, r.__leg.flight_number, (r.__leg.from || r.__leg.to) ? [r.__leg.from, r.__leg.to].filter(Boolean).join(' → ') : ''].filter(Boolean).join(' · ')
|
||||||
|
} else {
|
||||||
|
sub = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
|
||||||
|
}
|
||||||
|
}
|
||||||
else if (r.type === 'train') sub = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : ''].filter(Boolean).join(' · ')
|
else if (r.type === 'train') sub = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : ''].filter(Boolean).join(' · ')
|
||||||
return (
|
return (
|
||||||
<div key={`t-${r.id}`} className="bg-[rgba(59,130,246,0.06)]" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px', borderRadius: 6, border: '1px solid rgba(59,130,246,0.15)' }}>
|
<div key={r.__leg ? `t-${r.id}-leg${r.__leg.index}` : `t-${r.id}`} className="bg-[rgba(59,130,246,0.06)]" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px', borderRadius: 6, border: '1px solid rgba(59,130,246,0.15)' }}>
|
||||||
<div className="bg-[rgba(59,130,246,0.12)]" style={{ width: 24, height: 24, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
<div className="bg-[rgba(59,130,246,0.12)]" style={{ width: 24, height: 24, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
<TIcon size={12} color="#3b82f6" />
|
<TIcon size={12} color="#3b82f6" />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div className="text-[#111827]" style={{ fontSize: 12, fontWeight: 500 }}>{r.title}{time ? ` · ${time}` : ''}</div>
|
<div className="text-[#111827]" style={{ fontSize: 12, fontWeight: 500 }}>{r.title}{time ? ` · ${time}${endTime ? `–${endTime}` : ''}` : ''}</div>
|
||||||
{sub && <div className="text-[#6b7280]" style={{ fontSize: 10 }}>{sub}</div>}
|
{sub && <div className="text-[#6b7280]" style={{ fontSize: 10 }}>{sub}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -284,7 +293,11 @@ export default function SharedTripPage() {
|
|||||||
{date && <span>{date}</span>}
|
{date && <span>{date}</span>}
|
||||||
{time && <span>{time}</span>}
|
{time && <span>{time}</span>}
|
||||||
{r.location && <span>{r.location}</span>}
|
{r.location && <span>{r.location}</span>}
|
||||||
{meta.airline && <span>{meta.airline} {meta.flight_number || ''}</span>}
|
{r.type === 'flight'
|
||||||
|
? getFlightLegs(r).map((leg, i) => (
|
||||||
|
<span key={i}>{[leg.airline, leg.flight_number, (leg.from || leg.to) ? [leg.from, leg.to].filter(Boolean).join(' → ') : ''].filter(Boolean).join(' ')}</span>
|
||||||
|
))
|
||||||
|
: meta.airline && <span>{meta.airline} {meta.flight_number || ''}</span>}
|
||||||
{meta.train_number && <span>{meta.train_number}</span>}
|
{meta.train_number && <span>{meta.train_number}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1160,10 +1160,13 @@ describe('TripPlannerPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('FE-PAGE-PLANNER-041: handleSaveReservation edit path covers update reservation', () => {
|
describe('FE-PAGE-PLANNER-041: handleSaveReservation edit path covers update reservation', () => {
|
||||||
it('calls onEdit then onSave on ReservationModal to exercise the edit-reservation handler', async () => {
|
it('does not force a day_id on edit so the server keeps/derives it (#1237)', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
|
||||||
seedTripStore({ id: 42 });
|
seedTripStore({ id: 42 });
|
||||||
|
// Capture the update payload — tripActions is a snapshot of the store at mount.
|
||||||
|
const updateReservationSpy = vi.fn().mockResolvedValue({ id: 1, day_id: 7 });
|
||||||
|
seedStore(useTripStore, { updateReservation: updateReservationSpy } as any);
|
||||||
|
|
||||||
renderPlannerPage(42);
|
renderPlannerPage(42);
|
||||||
|
|
||||||
@@ -1179,20 +1182,24 @@ describe('TripPlannerPage', () => {
|
|||||||
expect(screen.getByTestId('reservations-panel')).toBeInTheDocument();
|
expect(screen.getByTestId('reservations-panel')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set editingReservation via captured onEdit prop (inline lambda in JSX)
|
// Edit a reservation that lives on day 7 (no day is selected — Book tab).
|
||||||
const fakeReservation = { id: 1, trip_id: 42, name: 'Test', type: 'restaurant', status: 'confirmed' };
|
const fakeReservation = { id: 1, trip_id: 42, name: 'Test', type: 'other', status: 'confirmed', day_id: 7 };
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
capturedReservationsPanelProps.current.onEdit?.(fakeReservation);
|
capturedReservationsPanelProps.current.onEdit?.(fakeReservation);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Call onSave — now takes edit path (editingReservation is set)
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await capturedReservationModalProps.current.onSave?.({
|
await capturedReservationModalProps.current.onSave?.({
|
||||||
name: 'Updated Booking',
|
name: 'Updated Booking',
|
||||||
type: 'restaurant',
|
type: 'tour',
|
||||||
status: 'confirmed',
|
status: 'confirmed',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The client must NOT send a day_id (no forcing to the selected day, no
|
||||||
|
// stale value) — the server keeps/derives it from the booking's date.
|
||||||
|
expect(updateReservationSpy).toHaveBeenCalled();
|
||||||
|
expect(updateReservationSpy.mock.calls[0][2]).not.toHaveProperty('day_id');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ import PackingListPanel from '../components/Packing/PackingListPanel'
|
|||||||
import ApplyTemplateButton from '../components/Packing/ApplyTemplateButton'
|
import ApplyTemplateButton from '../components/Packing/ApplyTemplateButton'
|
||||||
import TodoListPanel from '../components/Todo/TodoListPanel'
|
import TodoListPanel from '../components/Todo/TodoListPanel'
|
||||||
import FileManager from '../components/Files/FileManager'
|
import FileManager from '../components/Files/FileManager'
|
||||||
import CostsPanel from '../components/Budget/CostsPanel'
|
import CostsPanel, { ExpenseModal, type ExpensePrefill } from '../components/Budget/CostsPanel'
|
||||||
|
import type { BookingExpenseRequest } from '../components/Planner/BookingCostsSection.types'
|
||||||
|
import type { BudgetItem } from '../types'
|
||||||
import CollabPanel from '../components/Collab/CollabPanel'
|
import CollabPanel from '../components/Collab/CollabPanel'
|
||||||
import Navbar from '../components/Layout/Navbar'
|
import Navbar from '../components/Layout/Navbar'
|
||||||
import { useToast } from '../components/shared/Toast'
|
import { useToast } from '../components/shared/Toast'
|
||||||
@@ -201,7 +203,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
expandedDayIds, setExpandedDayIds, mapPlaces,
|
expandedDayIds, setExpandedDayIds, mapPlaces,
|
||||||
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
|
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
|
||||||
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
|
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
|
||||||
handleSavePlace, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
|
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
|
||||||
handleAssignToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
|
handleAssignToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
|
||||||
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
|
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
|
||||||
selectedPlace, dayOrderMap, dayPlaces,
|
selectedPlace, dayOrderMap, dayPlaces,
|
||||||
@@ -212,6 +214,18 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
const [glMap, setGlMap] = useState<import('mapbox-gl').Map | null>(null)
|
const [glMap, setGlMap] = useState<import('mapbox-gl').Map | null>(null)
|
||||||
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false
|
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false
|
||||||
|
|
||||||
|
// Costs expense editor opened from a booking modal (save-then-open). Lives at the
|
||||||
|
// page level so it has tripMembers / base currency / current user available.
|
||||||
|
const meId = useAuthStore(s => s.user?.id ?? -1)
|
||||||
|
const displayCurrency = useSettingsStore(s => s.settings.default_currency)
|
||||||
|
const costsBase = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
|
||||||
|
const loadBudgetItems = useTripStore(s => s.loadBudgetItems)
|
||||||
|
const [bookingExpense, setBookingExpense] = useState<{ editing: BudgetItem | null; prefill?: ExpensePrefill } | null>(null)
|
||||||
|
const openBookingExpense = (req: BookingExpenseRequest) => {
|
||||||
|
if (req.editItem) setBookingExpense({ editing: req.editItem })
|
||||||
|
else if (req.prefill) setBookingExpense({ editing: null, prefill: req.prefill })
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoading || !splashDone) {
|
if (isLoading || !splashDone) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-surface" style={{
|
<div className="bg-surface" style={{
|
||||||
@@ -451,7 +465,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
onPlaceClick={handlePlaceClick}
|
onPlaceClick={handlePlaceClick}
|
||||||
onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true) }}
|
onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true) }}
|
||||||
onAssignToDay={handleAssignToDay}
|
onAssignToDay={handleAssignToDay}
|
||||||
onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true) }}
|
onEditPlace={(place) => openPlaceEditor(place)}
|
||||||
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
|
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
|
||||||
onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)}
|
onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)}
|
||||||
onCategoryFilterChange={setMapCategoryFilter}
|
onCategoryFilterChange={setMapCategoryFilter}
|
||||||
@@ -517,17 +531,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
assignments={assignments}
|
assignments={assignments}
|
||||||
reservations={reservations}
|
reservations={reservations}
|
||||||
onClose={() => setSelectedPlaceId(null)}
|
onClose={() => setSelectedPlaceId(null)}
|
||||||
onEdit={() => {
|
onEdit={() => openPlaceEditor(selectedPlace, selectedAssignmentId)}
|
||||||
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)}
|
onDelete={() => handleDeletePlace(selectedPlace.id)}
|
||||||
onAssignToDay={handleAssignToDay}
|
onAssignToDay={handleAssignToDay}
|
||||||
onRemoveAssignment={handleRemoveAssignment}
|
onRemoveAssignment={handleRemoveAssignment}
|
||||||
@@ -565,18 +569,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
assignments={assignments}
|
assignments={assignments}
|
||||||
reservations={reservations}
|
reservations={reservations}
|
||||||
onClose={() => setSelectedPlaceId(null)}
|
onClose={() => setSelectedPlaceId(null)}
|
||||||
onEdit={() => {
|
onEdit={() => { openPlaceEditor(selectedPlace, selectedAssignmentId); setSelectedPlaceId(null) }}
|
||||||
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) }}
|
onDelete={() => { handleDeletePlace(selectedPlace.id); setSelectedPlaceId(null) }}
|
||||||
onAssignToDay={handleAssignToDay}
|
onAssignToDay={handleAssignToDay}
|
||||||
onRemoveAssignment={handleRemoveAssignment}
|
onRemoveAssignment={handleRemoveAssignment}
|
||||||
@@ -617,7 +610,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||||
{mobileSidebarOpen === 'left'
|
{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 />
|
? <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) => { 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 }} />
|
: <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 }} />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -703,11 +696,23 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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)} />
|
<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)} />
|
||||||
<TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripActions.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
|
<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} />
|
<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} />
|
<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} />
|
||||||
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) }} onSave={handleSaveTransport} reservation={editingTransport} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} />}
|
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) }} onSave={handleSaveTransport} reservation={editingTransport} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} onOpenExpense={openBookingExpense} />}
|
||||||
|
{bookingExpense && (
|
||||||
|
<ExpenseModal
|
||||||
|
tripId={tripId}
|
||||||
|
base={costsBase}
|
||||||
|
people={tripMembers}
|
||||||
|
me={meId}
|
||||||
|
editing={bookingExpense.editing}
|
||||||
|
prefill={bookingExpense.prefill}
|
||||||
|
onClose={() => setBookingExpense(null)}
|
||||||
|
onSaved={() => { setBookingExpense(null); loadBudgetItems(tripId) }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<BookingImportModal isOpen={showBookingImport} onClose={() => setShowBookingImport(false)} tripId={tripId} pushUndo={pushUndo} />
|
<BookingImportModal isOpen={showBookingImport} onClose={() => setShowBookingImport(false)} tripId={tripId} pushUndo={pushUndo} />
|
||||||
<AirTrailImportModal isOpen={showAirTrailImport} onClose={() => setShowAirTrailImport(false)} tripId={tripId} pushUndo={pushUndo} />
|
<AirTrailImportModal isOpen={showAirTrailImport} onClose={() => setShowAirTrailImport(false)} tripId={tripId} pushUndo={pushUndo} />
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import apiClient, { mapsApi } from '../../api/client'
|
|||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
import type { GeoJsonFeatureCollection } from '../../types'
|
import type { GeoJsonFeatureCollection } from '../../types'
|
||||||
import { A2_TO_A3, type AtlasData, type CountryDetail, type BucketItem } from './atlasModel'
|
import { A2_TO_A3, type AtlasData, type CountryDetail, type BucketItem } from './atlasModel'
|
||||||
|
import { continentForCountry } from '@trek/shared'
|
||||||
|
|
||||||
function useCountryNames(language: string): (code: string) => string {
|
function useCountryNames(language: string): (code: string) => string {
|
||||||
const [resolver, setResolver] = useState<(code: string) => string>(() => (code: string) => code)
|
const [resolver, setResolver] = useState<(code: string) => string>(() => (code: string) => code)
|
||||||
@@ -340,7 +341,10 @@ export function useAtlas() {
|
|||||||
</div>
|
</div>
|
||||||
</div>`
|
</div>`
|
||||||
layer.bindTooltip(tooltipHtml, {
|
layer.bindTooltip(tooltipHtml, {
|
||||||
sticky: false, permanent: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
|
// sticky so the tooltip tracks the cursor; non-sticky anchors it at the feature's
|
||||||
|
// bounds centre, which for countries with overseas territories (e.g. France) lands
|
||||||
|
// far out in the ocean instead of over the area being hovered.
|
||||||
|
sticky: true, permanent: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
|
||||||
})
|
})
|
||||||
layer.on('click', () => {
|
layer.on('click', () => {
|
||||||
if (c.placeCount === 0 && c.tripCount === 0) {
|
if (c.placeCount === 0 && c.tripCount === 0) {
|
||||||
@@ -363,7 +367,7 @@ export function useAtlas() {
|
|||||||
country_layer_by_a2_ref.current[countryCode] = layer
|
country_layer_by_a2_ref.current[countryCode] = layer
|
||||||
const name = feature.properties?.NAME || feature.properties?.ADMIN || resolveName(countryCode)
|
const name = feature.properties?.NAME || feature.properties?.ADMIN || resolveName(countryCode)
|
||||||
layer.bindTooltip(`<div style="font-size:12px;font-weight:600">${name}</div>`, {
|
layer.bindTooltip(`<div style="font-size:12px;font-weight:600">${name}</div>`, {
|
||||||
sticky: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
|
sticky: true, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
|
||||||
})
|
})
|
||||||
layer.on('click', () => handleMarkCountry(countryCode, name))
|
layer.on('click', () => handleMarkCountry(countryCode, name))
|
||||||
layer.on('mouseover', (e) => {
|
layer.on('mouseover', (e) => {
|
||||||
@@ -552,6 +556,20 @@ export function useAtlas() {
|
|||||||
} catch (e ) {
|
} catch (e ) {
|
||||||
console.error('Error fitting bounds', e)
|
console.error('Error fitting bounds', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mirror the map-click behaviour so an already-visited country can be removed
|
||||||
|
// straight from search. Tiny countries (Vatican City, Singapore) are hard to
|
||||||
|
// hit on the map, so search was the only way in — but it always opened the
|
||||||
|
// "Mark / Bucket" dialog with no Remove option.
|
||||||
|
const visited = data?.countries.find(c => c.code === country_code)
|
||||||
|
if (visited) {
|
||||||
|
if (visited.placeCount === 0 && visited.tripCount === 0) {
|
||||||
|
handleUnmarkCountry(country_code)
|
||||||
|
} else {
|
||||||
|
loadCountryDetailRef.current(country_code)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
setConfirmAction({ type: 'choose', code: country_code, name: country_label })
|
setConfirmAction({ type: 'choose', code: country_code, name: country_label })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,10 +583,12 @@ export function useAtlas() {
|
|||||||
apiClient.post(`/addons/atlas/country/${code}/mark`).catch(() => {})
|
apiClient.post(`/addons/atlas/country/${code}/mark`).catch(() => {})
|
||||||
setData(prev => {
|
setData(prev => {
|
||||||
if (!prev || prev.countries.find(c => c.code === code)) return prev
|
if (!prev || prev.countries.find(c => c.code === code)) return prev
|
||||||
|
const cont = continentForCountry(code)
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
countries: [...prev.countries, { code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }],
|
countries: [...prev.countries, { code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }],
|
||||||
stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 },
|
stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 },
|
||||||
|
continents: { ...prev.continents, [cont]: (prev.continents?.[cont] || 0) + 1 },
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -579,10 +599,12 @@ export function useAtlas() {
|
|||||||
if (!prev) return prev
|
if (!prev) return prev
|
||||||
const c = prev.countries.find(c => c.code === code)
|
const c = prev.countries.find(c => c.code === code)
|
||||||
if (!c || c.placeCount > 0 || c.tripCount > 0) return prev
|
if (!c || c.placeCount > 0 || c.tripCount > 0) return prev
|
||||||
|
const cont = continentForCountry(code)
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
countries: prev.countries.filter(c => c.code !== code),
|
countries: prev.countries.filter(c => c.code !== code),
|
||||||
stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
|
stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
|
||||||
|
continents: { ...prev.continents, [cont]: Math.max(0, (prev.continents?.[cont] || 0) - 1) },
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
setVisitedRegions(prev => {
|
setVisitedRegions(prev => {
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* 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,6 +18,7 @@ import { usePlaceSelection } from '../../hooks/usePlaceSelection'
|
|||||||
import { usePlannerHistory } from '../../hooks/usePlannerHistory'
|
import { usePlannerHistory } from '../../hooks/usePlannerHistory'
|
||||||
import { useAirtrailConnection } from '../../hooks/useAirtrailConnection'
|
import { useAirtrailConnection } from '../../hooks/useAirtrailConnection'
|
||||||
import type { Accommodation, TripMember, Day, Place, Reservation } from '../../types'
|
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
|
* Trip planner page logic — the big one. Owns the trip store wiring, addon
|
||||||
@@ -423,6 +424,16 @@ export function useTripPlanner() {
|
|||||||
}
|
}
|
||||||
}, [editingPlace, editingAssignmentId, tripId, toast, pushUndo])
|
}, [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) => {
|
const handleDeletePlace = useCallback((placeId) => {
|
||||||
setDeletePlaceId(placeId)
|
setDeletePlaceId(placeId)
|
||||||
}, [])
|
}, [])
|
||||||
@@ -568,7 +579,12 @@ export function useTripPlanner() {
|
|||||||
const handleSaveReservation = async (data: Record<string, string | number | null> & { title: string }) => {
|
const handleSaveReservation = async (data: Record<string, string | number | null> & { title: string }) => {
|
||||||
try {
|
try {
|
||||||
if (editingReservation) {
|
if (editingReservation) {
|
||||||
const r = await tripActions.updateReservation(tripId, editingReservation.id, { ...data, day_id: selectedDayId || null })
|
// Don't force a day here. The old code pinned it to the (often empty)
|
||||||
|
// selected day, which dropped the booking out of the Plan; preserving the
|
||||||
|
// old day_id instead left it stale when the date changed. Omitting it lets
|
||||||
|
// the server derive the day from the booking's date, or keep the current
|
||||||
|
// one when there is no date.
|
||||||
|
const r = await tripActions.updateReservation(tripId, editingReservation.id, data)
|
||||||
toast.success(t('trip.toast.reservationUpdated'))
|
toast.success(t('trip.toast.reservationUpdated'))
|
||||||
setShowReservationModal(false)
|
setShowReservationModal(false)
|
||||||
setEditingReservation(null)
|
setEditingReservation(null)
|
||||||
@@ -685,7 +701,7 @@ export function useTripPlanner() {
|
|||||||
expandedDayIds, setExpandedDayIds, mapPlaces,
|
expandedDayIds, setExpandedDayIds, mapPlaces,
|
||||||
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
|
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
|
||||||
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
|
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
|
||||||
handleSavePlace, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
|
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
|
||||||
handleAssignToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
|
handleAssignToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
|
||||||
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
|
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
|
||||||
selectedPlace, dayOrderMap, dayPlaces,
|
selectedPlace, dayOrderMap, dayPlaces,
|
||||||
|
|||||||
Generated
+1316
-1456
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -30,7 +30,8 @@
|
|||||||
"comment:overrides": "Force a single React 19 across the workspace so the test renderer (@testing-library/react) and the app share one react-dom.",
|
"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": {
|
"overrides": {
|
||||||
"react": "19.2.6",
|
"react": "19.2.6",
|
||||||
"react-dom": "19.2.6"
|
"react-dom": "19.2.6",
|
||||||
|
"multer": "^2.2.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rollup/rollup-linux-x64-musl": "4.62.0",
|
"@rollup/rollup-linux-x64-musl": "4.62.0",
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ OIDC_SCOPE=openid email profile # Fully overrides the default. Add extra scopes
|
|||||||
|
|
||||||
DEMO_MODE=false # Demo mode - resets data hourly
|
DEMO_MODE=false # Demo mode - resets data hourly
|
||||||
|
|
||||||
|
# BACKUP_UPLOAD_LIMIT_MB=500 # Max size (MB) of a backup archive you can upload when restoring. Raise it if your backup exceeds 500 MB. If you sit behind a reverse proxy, raise its upload limit too (e.g. nginx client_max_body_size).
|
||||||
|
|
||||||
# MCP_RATE_LIMIT=300 # Max MCP API requests per user per minute (default: 300)
|
# MCP_RATE_LIMIT=300 # Max MCP API requests per user per minute (default: 300)
|
||||||
# MCP_MAX_SESSION_PER_USER=20 # Max concurrent MCP sessions per user (default: 20)
|
# MCP_MAX_SESSION_PER_USER=20 # Max concurrent MCP sessions per user (default: 20)
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
+3
-4
@@ -38,9 +38,8 @@
|
|||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
"jimp": "^1.6.1",
|
"jimp": "^1.6.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"multer": "^2.1.1",
|
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"nodemailer": "^8.0.5",
|
"nodemailer": "^9.0.1",
|
||||||
"otplib": "^12.0.1",
|
"otplib": "^12.0.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
@@ -60,7 +59,7 @@
|
|||||||
"@hono/node-server": "^1.19.13",
|
"@hono/node-server": "^1.19.13",
|
||||||
"picomatch": "^4.0.4",
|
"picomatch": "^4.0.4",
|
||||||
"ip-address": "^10.1.1",
|
"ip-address": "^10.1.1",
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.2.0",
|
||||||
"ws": "^8.21.0",
|
"ws": "^8.21.0",
|
||||||
"qs": "^6.15.2",
|
"qs": "^6.15.2",
|
||||||
"file-type": "^21.3.4"
|
"file-type": "^21.3.4"
|
||||||
@@ -80,7 +79,7 @@
|
|||||||
"@types/multer": "^2.1.0",
|
"@types/multer": "^2.1.0",
|
||||||
"@types/node": "^25.5.0",
|
"@types/node": "^25.5.0",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
"@types/nodemailer": "^7.0.11",
|
"@types/nodemailer": "^8.0.1",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/semver": "^7.7.1",
|
"@types/semver": "^7.7.1",
|
||||||
"@types/supertest": "^6.0.3",
|
"@types/supertest": "^6.0.3",
|
||||||
|
|||||||
@@ -151,18 +151,37 @@ function normalizeAdm0Feature(f) {
|
|||||||
|
|
||||||
function normalizeAdm1(geo, a3, countryName) {
|
function normalizeAdm1(geo, a3, countryName) {
|
||||||
if (!geo?.features) return []
|
if (!geo?.features) return []
|
||||||
|
const a2 = A3_TO_A2[a3] || null
|
||||||
|
// Ensure every region in a country ends up with a distinct iso_3166_2 — the Atlas
|
||||||
|
// marks/unmarks regions by this code, so duplicates make one mark light up the whole
|
||||||
|
// country.
|
||||||
|
const used = new Set()
|
||||||
|
const uniq = (base) => {
|
||||||
|
let code = base, n = 2
|
||||||
|
while (used.has(code)) code = `${base}-${n++}`
|
||||||
|
used.add(code)
|
||||||
|
return code
|
||||||
|
}
|
||||||
return geo.features.map(f => {
|
return geo.features.map(f => {
|
||||||
const name = f.properties?.shapeName || ''
|
const name = f.properties?.shapeName || ''
|
||||||
const geometry = quantizeGeometry(f.geometry, ADM1_DECIMALS)
|
const geometry = quantizeGeometry(f.geometry, ADM1_DECIMALS)
|
||||||
if (!geometry) return null
|
if (!geometry) return null
|
||||||
const a2 = A3_TO_A2[a3] || null
|
// shapeISO is a real ISO 3166-2 code for most features, but geoBoundaries sometimes
|
||||||
// shapeISO is a real ISO 3166-2 code for ~90% of features; geoBoundaries leaves the
|
// fills it with the bare country code instead of a subdivision code — e.g. every
|
||||||
// rest blank or uses an `XX_YYY` placeholder. Keep real/placeholder codes as-is
|
// Spanish region gets "ESP", every Chinese "CHN" (also CL/OM). Keep it only when it
|
||||||
// (stable per polygon → manual mark/unmark works, real ones match Nominatim). For
|
// is a real `XX-…` subdivision code and not already taken; otherwise synthesize a
|
||||||
// blank codes, synthesize a stable id mirroring the server's geocode fallback so
|
// stable, unique-per-country id from the region name so each region is independently
|
||||||
// every region is still markable.
|
// markable.
|
||||||
let code = f.properties?.shapeISO || ''
|
const raw = f.properties?.shapeISO || ''
|
||||||
if (!code && a2) code = `${a2}-${name.replace(/[^A-Za-z0-9]/g, '').substring(0, 3).toUpperCase()}`
|
let code
|
||||||
|
if (/^[A-Za-z]{2}-[A-Za-z0-9]+$/.test(raw) && !used.has(raw)) {
|
||||||
|
code = raw
|
||||||
|
used.add(code)
|
||||||
|
} else if (a2) {
|
||||||
|
code = uniq(`${a2}-${name.replace(/[^A-Za-z0-9]/g, '').toUpperCase() || 'RGN'}`)
|
||||||
|
} else {
|
||||||
|
code = raw
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
type: 'Feature',
|
type: 'Feature',
|
||||||
// Property names the Atlas region layer + server getRegionGeo already read.
|
// Property names the Atlas region layer + server getRegionGeo already read.
|
||||||
|
|||||||
+56
-36
@@ -1,39 +1,11 @@
|
|||||||
|
import { SUPPORTED_LANGUAGE_CODES as SUPPORTED_LANG_CODES } from '@trek/shared';
|
||||||
|
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { SUPPORTED_LANGUAGE_CODES as SUPPORTED_LANG_CODES } from '@trek/shared';
|
|
||||||
|
|
||||||
const dataDir = path.resolve(__dirname, '../data');
|
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');
|
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
|
// ENCRYPTION_KEY is used to derive at-rest encryption keys for stored secrets
|
||||||
// (API keys, MFA TOTP secrets, SMTP password, OIDC client secret, etc.).
|
// (API keys, MFA TOTP secrets, SMTP password, OIDC client secret, etc.).
|
||||||
@@ -93,18 +65,55 @@ if (_encryptionKey) {
|
|||||||
fs.writeFileSync(encKeyFile, _encryptionKey, { mode: 0o600 });
|
fs.writeFileSync(encKeyFile, _encryptionKey, { mode: 0o600 });
|
||||||
console.log('Encryption key persisted to', encKeyFile);
|
console.log('Encryption key persisted to', encKeyFile);
|
||||||
} catch (writeErr: unknown) {
|
} 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.');
|
console.warn('Set ENCRYPTION_KEY env var to avoid losing access to encrypted secrets on restart.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ENCRYPTION_KEY = _encryptionKey;
|
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
|
// 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.
|
// selects one. Only applies when the user has no saved language preference.
|
||||||
const rawDefaultLang = process.env.DEFAULT_LANGUAGE?.toLowerCase() || 'en';
|
const rawDefaultLang = process.env.DEFAULT_LANGUAGE?.toLowerCase() || 'en';
|
||||||
if (!SUPPORTED_LANG_CODES.includes(rawDefaultLang)) {
|
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';
|
export const DEFAULT_LANGUAGE = SUPPORTED_LANG_CODES.includes(rawDefaultLang) ? rawDefaultLang : 'en';
|
||||||
|
|
||||||
@@ -116,7 +125,13 @@ export const DEFAULT_LANGUAGE = SUPPORTED_LANG_CODES.includes(rawDefaultLang) ?
|
|||||||
// challenge token or MCP OAuth tokens — those keep their own TTL.
|
// challenge token or MCP OAuth tokens — those keep their own TTL.
|
||||||
const DEFAULT_SESSION_DURATION = '24h';
|
const DEFAULT_SESSION_DURATION = '24h';
|
||||||
const DURATION_UNITS_MS: Record<string, number> = {
|
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 {
|
function parseDurationMs(value: string): number | null {
|
||||||
const m = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w|y)?$/i.exec(value.trim());
|
const m = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w|y)?$/i.exec(value.trim());
|
||||||
@@ -128,7 +143,9 @@ function parseDurationMs(value: string): number | null {
|
|||||||
const rawSessionDuration = process.env.SESSION_DURATION?.trim() || DEFAULT_SESSION_DURATION;
|
const rawSessionDuration = process.env.SESSION_DURATION?.trim() || DEFAULT_SESSION_DURATION;
|
||||||
const parsedSessionMs = parseDurationMs(rawSessionDuration);
|
const parsedSessionMs = parseDurationMs(rawSessionDuration);
|
||||||
if (parsedSessionMs == null) {
|
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). */
|
/** Human-readable session length actually in effect (for logs/diagnostics). */
|
||||||
export const SESSION_DURATION = parsedSessionMs == null ? DEFAULT_SESSION_DURATION : rawSessionDuration;
|
export const SESSION_DURATION = parsedSessionMs == null ? DEFAULT_SESSION_DURATION : rawSessionDuration;
|
||||||
@@ -146,10 +163,13 @@ const DEFAULT_SESSION_DURATION_REMEMBER = '30d';
|
|||||||
const rawRememberDuration = process.env.SESSION_DURATION_REMEMBER?.trim() || DEFAULT_SESSION_DURATION_REMEMBER;
|
const rawRememberDuration = process.env.SESSION_DURATION_REMEMBER?.trim() || DEFAULT_SESSION_DURATION_REMEMBER;
|
||||||
const parsedRememberMs = parseDurationMs(rawRememberDuration);
|
const parsedRememberMs = parseDurationMs(rawRememberDuration);
|
||||||
if (parsedRememberMs == null) {
|
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). */
|
/** 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`. */
|
/** "Remember me" session length in milliseconds — used for the persistent cookie `maxAge`. */
|
||||||
export const SESSION_DURATION_REMEMBER_MS = parsedRememberMs ?? parseDurationMs(DEFAULT_SESSION_DURATION_REMEMBER)!;
|
export const SESSION_DURATION_REMEMBER_MS = parsedRememberMs ?? parseDurationMs(DEFAULT_SESSION_DURATION_REMEMBER)!;
|
||||||
/** "Remember me" session length in seconds — passed to `jwt.sign({ expiresIn })`. */
|
/** "Remember me" session length in seconds — passed to `jwt.sign({ expiresIn })`. */
|
||||||
|
|||||||
@@ -3045,6 +3045,15 @@ function runMigrations(db: Database.Database): void {
|
|||||||
'CREATE UNIQUE INDEX IF NOT EXISTS idx_reservations_external ON reservations(external_source, external_id, trip_id)',
|
'CREATE UNIQUE INDEX IF NOT EXISTS idx_reservations_external ON reservations(external_source, external_id, trip_id)',
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
() => {
|
||||||
|
// Per-user opt-in for writing TREK edits back to AirTrail (#1240). Default
|
||||||
|
// off: AirTrail is the source of truth and TREK never writes unless asked.
|
||||||
|
try {
|
||||||
|
db.exec('ALTER TABLE users ADD COLUMN airtrail_write_enabled INTEGER DEFAULT 0');
|
||||||
|
} catch (err: any) {
|
||||||
|
if (!err.message?.includes('duplicate column name')) throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (currentVersion < migrations.length) {
|
if (currentVersion < migrations.length) {
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ function seedAdminAccount(db: Database.Database): void {
|
|||||||
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
|
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
|
||||||
if (userCount > 0) return;
|
if (userCount > 0) return;
|
||||||
|
|
||||||
|
// Demo mode seeds its own admin (admin@trek.app, username 'admin') right after this.
|
||||||
|
// Creating a first-run admin here would grab username 'admin' first and make the demo
|
||||||
|
// seeder fail on the UNIQUE(username) constraint, leaving the demo user uncreated.
|
||||||
|
if (process.env.DEMO_MODE?.toLowerCase() === 'true') return;
|
||||||
|
|
||||||
if (isOidcOnlyConfigured()) {
|
if (isOidcOnlyConfigured()) {
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('╔══════════════════════════════════════════════╗');
|
console.log('╔══════════════════════════════════════════════╗');
|
||||||
|
|||||||
@@ -66,6 +66,17 @@ export function hasTripPermission(action: string, tripId: number | string, userI
|
|||||||
return checkPermission(action, userRow?.role ?? 'user', tripOwnerId, userId, tripOwnerId !== userId);
|
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) {
|
export function ok(data: unknown) {
|
||||||
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
|
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,7 +136,11 @@ export function registerAtlasTools(server: McpServer, userId: number, scopes: st
|
|||||||
async ({ regionCode, regionName, countryCode }) => {
|
async ({ regionCode, regionName, countryCode }) => {
|
||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
markRegionVisited(userId, regionCode, regionName, countryCode);
|
markRegionVisited(userId, regionCode, regionName, countryCode);
|
||||||
const region = listManuallyVisitedRegions(userId).find(r => r.region_code === regionCode);
|
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;
|
||||||
return ok({ region });
|
return ok({ region });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
+160
-25
@@ -5,18 +5,42 @@ import { isDemoUser } from '../../services/authService';
|
|||||||
import {
|
import {
|
||||||
createBudgetItem, updateBudgetItem, deleteBudgetItem,
|
createBudgetItem, updateBudgetItem, deleteBudgetItem,
|
||||||
updateMembers as updateBudgetMembers,
|
updateMembers as updateBudgetMembers,
|
||||||
toggleMemberPaid,
|
toggleMemberPaid, getBudgetItem,
|
||||||
|
calculateSettlement, listSettlements, createSettlement, updateSettlement, deleteSettlement,
|
||||||
} from '../../services/budgetService';
|
} from '../../services/budgetService';
|
||||||
|
import { getRates } from '../../services/exchangeRateService';
|
||||||
|
import { getTripOwner, listMembers } from '../../services/tripService';
|
||||||
import {
|
import {
|
||||||
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
|
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_READONLY,
|
||||||
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||||
demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
|
demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
|
||||||
} from './_shared';
|
} from './_shared';
|
||||||
import { canWrite } from '../scopes';
|
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 { isAddonEnabled } from '../../services/adminService';
|
import { isAddonEnabled } from '../../services/adminService';
|
||||||
import { ADDON_IDS } from '../../addons';
|
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 {
|
export function registerBudgetTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
||||||
|
const R = canRead(scopes, 'budget');
|
||||||
const W = canWrite(scopes, 'budget');
|
const W = canWrite(scopes, 'budget');
|
||||||
|
|
||||||
if (isAddonEnabled(ADDON_IDS.BUDGET)) {
|
if (isAddonEnabled(ADDON_IDS.BUDGET)) {
|
||||||
@@ -25,21 +49,26 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
|
|||||||
if (W) server.registerTool(
|
if (W) server.registerTool(
|
||||||
'create_budget_item',
|
'create_budget_item',
|
||||||
{
|
{
|
||||||
description: 'Add a budget/expense item to a trip.',
|
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.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
tripId: z.number().int().positive(),
|
tripId: z.number().int().positive(),
|
||||||
name: z.string().min(1).max(200),
|
name: z.string().min(1).max(200),
|
||||||
category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'),
|
category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'),
|
||||||
total_price: z.number().nonnegative(),
|
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(),
|
note: z.string().max(500).optional(),
|
||||||
},
|
},
|
||||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||||
},
|
},
|
||||||
async ({ tripId, name, category, total_price, note }) => {
|
async ({ tripId, name, category, total_price, currency, member_ids, payers, expense_date, note }) => {
|
||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||||
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
||||||
const item = createBudgetItem(tripId, { category, name, total_price, note });
|
const members = resolveMemberIds(tripId, member_ids);
|
||||||
|
const item = createBudgetItem(tripId, { category, name, total_price, currency, member_ids: members, payers, expense_date, note });
|
||||||
safeBroadcast(tripId, 'budget:created', { item });
|
safeBroadcast(tripId, 'budget:created', { item });
|
||||||
return ok({ item });
|
return ok({ item });
|
||||||
}
|
}
|
||||||
@@ -71,24 +100,26 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
|
|||||||
if (W) server.registerTool(
|
if (W) server.registerTool(
|
||||||
'update_budget_item',
|
'update_budget_item',
|
||||||
{
|
{
|
||||||
description: 'Update an existing budget/expense item in a trip.',
|
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.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
tripId: z.number().int().positive(),
|
tripId: z.number().int().positive(),
|
||||||
itemId: z.number().int().positive(),
|
itemId: z.number().int().positive(),
|
||||||
name: z.string().min(1).max(200).optional(),
|
name: z.string().min(1).max(200).optional(),
|
||||||
category: z.string().max(100).optional(),
|
category: z.string().max(100).optional(),
|
||||||
total_price: z.number().nonnegative().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(),
|
persons: z.number().int().positive().nullable().optional(),
|
||||||
days: z.number().int().positive().nullable().optional(),
|
days: z.number().int().positive().nullable().optional(),
|
||||||
note: z.string().max(500).nullable().optional(),
|
note: z.string().max(500).nullable().optional(),
|
||||||
},
|
},
|
||||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||||
},
|
},
|
||||||
async ({ tripId, itemId, name, category, total_price, persons, days, note }) => {
|
async ({ tripId, itemId, name, category, total_price, member_ids, payers, persons, days, note }) => {
|
||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||||
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
||||||
const item = updateBudgetItem(itemId, tripId, { name, category, total_price, persons, days, note });
|
const item = updateBudgetItem(itemId, tripId, { name, category, total_price, member_ids, payers, persons, days, note });
|
||||||
if (!item) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
|
if (!item) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
|
||||||
safeBroadcast(tripId, 'budget:updated', { item });
|
safeBroadcast(tripId, 'budget:updated', { item });
|
||||||
return ok({ item });
|
return ok({ item });
|
||||||
@@ -100,14 +131,14 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
|
|||||||
if (W) server.registerTool(
|
if (W) server.registerTool(
|
||||||
'create_budget_item_with_members',
|
'create_budget_item_with_members',
|
||||||
{
|
{
|
||||||
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.',
|
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.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
tripId: z.number().int().positive(),
|
tripId: z.number().int().positive(),
|
||||||
name: z.string().min(1).max(200),
|
name: z.string().min(1).max(200),
|
||||||
category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'),
|
category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'),
|
||||||
total_price: z.number().nonnegative(),
|
total_price: z.number().nonnegative(),
|
||||||
note: z.string().max(500).optional(),
|
note: z.string().max(500).optional(),
|
||||||
userIds: z.array(z.number().int().positive()).optional().describe('User IDs splitting this item; omit or pass empty array to skip member assignment'),
|
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'),
|
||||||
},
|
},
|
||||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||||
},
|
},
|
||||||
@@ -115,19 +146,16 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
|
|||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||||
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
||||||
const hasMembers = userIds && userIds.length > 0;
|
// Omitted userIds → default to the whole trip, matching create_budget_item.
|
||||||
|
const members = (userIds && userIds.length > 0) ? userIds : resolveMemberIds(tripId, undefined);
|
||||||
try {
|
try {
|
||||||
const run = db.transaction(() => {
|
const item = db.transaction(() => {
|
||||||
const item = createBudgetItem(tripId, { category, name, total_price, note });
|
const created = createBudgetItem(tripId, { category, name, total_price, note, member_ids: members });
|
||||||
if (hasMembers) {
|
return getBudgetItem(created.id, tripId)!;
|
||||||
return updateBudgetMembers(item.id, tripId, userIds!);
|
})();
|
||||||
}
|
safeBroadcast(tripId, 'budget:created', { item });
|
||||||
return { item };
|
if (members && members.length > 0) safeBroadcast(tripId, 'budget:members-updated', { item });
|
||||||
});
|
return ok({ 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 {
|
} catch {
|
||||||
return { content: [{ type: 'text' as const, text: 'Failed to create budget item.' }], isError: true };
|
return { content: [{ type: 'text' as const, text: 'Failed to create budget item.' }], isError: true };
|
||||||
}
|
}
|
||||||
@@ -137,7 +165,7 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
|
|||||||
if (W) server.registerTool(
|
if (W) server.registerTool(
|
||||||
'set_budget_item_members',
|
'set_budget_item_members',
|
||||||
{
|
{
|
||||||
description: 'Set which trip members are splitting a budget item (replaces current member list).',
|
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.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
tripId: z.number().int().positive(),
|
tripId: z.number().int().positive(),
|
||||||
itemId: z.number().int().positive(),
|
itemId: z.number().int().positive(),
|
||||||
@@ -149,7 +177,9 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
|
|||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||||
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
||||||
const item = updateBudgetMembers(itemId, tripId, userIds);
|
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);
|
||||||
safeBroadcast(tripId, 'budget:members-updated', { item });
|
safeBroadcast(tripId, 'budget:members-updated', { item });
|
||||||
return ok({ item });
|
return ok({ item });
|
||||||
}
|
}
|
||||||
@@ -176,5 +206,110 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
|
|||||||
return ok({ member });
|
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)
|
} // isAddonEnabled(BUDGET)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,19 +99,20 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
|||||||
start_day_id: z.number().int().positive().describe('Check-in day ID'),
|
start_day_id: z.number().int().positive().describe('Check-in day ID'),
|
||||||
end_day_id: z.number().int().positive().describe('Check-out 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: 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"'),
|
check_out: z.string().max(10).optional().describe('Check-out time e.g. "11:00"'),
|
||||||
confirmation: z.string().max(100).optional(),
|
confirmation: z.string().max(100).optional(),
|
||||||
notes: z.string().max(1000).optional(),
|
notes: z.string().max(1000).optional(),
|
||||||
},
|
},
|
||||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||||
},
|
},
|
||||||
async ({ tripId, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes }) => {
|
async ({ tripId, place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes }) => {
|
||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||||
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
|
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
|
||||||
const errors = validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id);
|
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 };
|
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_out, confirmation, notes });
|
const accommodation = createAccommodation(tripId, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes });
|
||||||
safeBroadcast(tripId, 'accommodation:created', { accommodation });
|
safeBroadcast(tripId, 'accommodation:created', { accommodation });
|
||||||
return ok({ accommodation });
|
return ok({ accommodation });
|
||||||
}
|
}
|
||||||
@@ -137,6 +138,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
|||||||
start_day_id: z.number().int().positive().describe('Check-in day ID'),
|
start_day_id: z.number().int().positive().describe('Check-in day ID'),
|
||||||
end_day_id: z.number().int().positive().describe('Check-out 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: 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"'),
|
check_out: z.string().max(10).optional().describe('Check-out time e.g. "11:00"'),
|
||||||
confirmation: z.string().max(100).optional(),
|
confirmation: z.string().max(100).optional(),
|
||||||
accommodation_notes: z.string().max(1000).optional().describe('Notes for the accommodation'),
|
accommodation_notes: z.string().max(1000).optional().describe('Notes for the accommodation'),
|
||||||
@@ -145,7 +147,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
|||||||
},
|
},
|
||||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
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_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_in_end, check_out, confirmation, accommodation_notes, price, currency }) => {
|
||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||||
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
|
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
|
||||||
@@ -154,7 +156,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
|||||||
try {
|
try {
|
||||||
const run = db.transaction(() => {
|
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 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_out, confirmation, notes: accommodation_notes });
|
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 });
|
||||||
return { place, accommodation };
|
return { place, accommodation };
|
||||||
});
|
});
|
||||||
const result = run();
|
const result = run();
|
||||||
@@ -178,19 +180,20 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
|||||||
start_day_id: z.number().int().positive().optional(),
|
start_day_id: z.number().int().positive().optional(),
|
||||||
end_day_id: z.number().int().positive().optional(),
|
end_day_id: z.number().int().positive().optional(),
|
||||||
check_in: z.string().max(10).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(),
|
check_out: z.string().max(10).optional(),
|
||||||
confirmation: z.string().max(100).optional(),
|
confirmation: z.string().max(100).optional(),
|
||||||
notes: z.string().max(1000).optional(),
|
notes: z.string().max(1000).optional(),
|
||||||
},
|
},
|
||||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||||
},
|
},
|
||||||
async ({ tripId, accommodationId, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes }) => {
|
async ({ tripId, accommodationId, place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes }) => {
|
||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||||
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
|
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
|
||||||
const existing = getAccommodation(accommodationId, tripId);
|
const existing = getAccommodation(accommodationId, tripId);
|
||||||
if (!existing) return { content: [{ type: 'text' as const, text: 'Accommodation not found.' }], isError: true };
|
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_out, confirmation, notes });
|
const accommodation = updateAccommodation(accommodationId, existing, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes });
|
||||||
safeBroadcast(tripId, 'accommodation:updated', { accommodation });
|
safeBroadcast(tripId, 'accommodation:updated', { accommodation });
|
||||||
return ok({ accommodation });
|
return ok({ accommodation });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,7 +136,9 @@ export function registerJourneyTools(server: McpServer, userId: number, scopes:
|
|||||||
async ({ title, subtitle, trip_ids }) => {
|
async ({ title, subtitle, trip_ids }) => {
|
||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
const journey = createJourney(userId, { title, subtitle, trip_ids });
|
const journey = createJourney(userId, { title, subtitle, trip_ids });
|
||||||
return ok({ journey });
|
// 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 });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -233,7 +235,9 @@ export function registerJourneyTools(server: McpServer, userId: number, scopes:
|
|||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
const entry = createEntry(journeyId, userId, { entry_date, title, story, entry_time, location_name, mood, sort_order });
|
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.');
|
if (!entry) return notFound('Journey not found or access denied.');
|
||||||
return ok({ entry });
|
// 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 });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -255,7 +259,9 @@ export function registerJourneyTools(server: McpServer, userId: number, scopes:
|
|||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
const entry = updateEntry(entryId, userId, { title, story, entry_date, entry_time, mood }, undefined);
|
const entry = updateEntry(entryId, userId, { title, story, entry_date, entry_time, mood }, undefined);
|
||||||
if (!entry) return notFound('Entry not found or access denied.');
|
if (!entry) return notFound('Entry not found or access denied.');
|
||||||
return ok({ entry });
|
// 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 });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -364,7 +370,8 @@ export function registerJourneyTools(server: McpServer, userId: number, scopes:
|
|||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
const result = updateJourneyPreferences(journeyId, userId, { hide_skeletons });
|
const result = updateJourneyPreferences(journeyId, userId, { hide_skeletons });
|
||||||
if (!result) return notFound('Journey not found or access denied.');
|
if (!result) return notFound('Journey not found or access denied.');
|
||||||
return ok({ success: true });
|
// Return the service result ({ hide_skeletons }), matching the REST route.
|
||||||
|
return ok(result);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -9,15 +9,16 @@ import {
|
|||||||
listBags, createBag, updateBag, deleteBag, setBagMembers,
|
listBags, createBag, updateBag, deleteBag, setBagMembers,
|
||||||
getCategoryAssignees as getPackingCategoryAssignees,
|
getCategoryAssignees as getPackingCategoryAssignees,
|
||||||
updateCategoryAssignees as updatePackingCategoryAssignees,
|
updateCategoryAssignees as updatePackingCategoryAssignees,
|
||||||
applyTemplate, saveAsTemplate, bulkImport,
|
applyTemplate, saveAsTemplate, listTemplates, bulkImport,
|
||||||
} from '../../services/packingService';
|
} from '../../services/packingService';
|
||||||
import {
|
import {
|
||||||
safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
|
safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
|
||||||
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||||
demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
|
demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
|
||||||
|
isAdminUser, adminRequired,
|
||||||
} from './_shared';
|
} from './_shared';
|
||||||
import { canRead, canWrite } from '../scopes';
|
import { canRead, canWrite } from '../scopes';
|
||||||
import { isAddonEnabled } from '../../services/adminService';
|
import { isAddonEnabled, deletePackingTemplate } from '../../services/adminService';
|
||||||
import { ADDON_IDS } from '../../addons';
|
import { ADDON_IDS } from '../../addons';
|
||||||
|
|
||||||
export function registerPackingTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
export function registerPackingTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
||||||
@@ -171,7 +172,9 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
|
|||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||||
const bag = createBag(tripId, { name, color });
|
// 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: [] };
|
||||||
safeBroadcast(tripId, 'packing:bag-created', { bag });
|
safeBroadcast(tripId, 'packing:bag-created', { bag });
|
||||||
return ok({ bag });
|
return ok({ bag });
|
||||||
}
|
}
|
||||||
@@ -197,7 +200,10 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
|
|||||||
const bodyKeys: string[] = [];
|
const bodyKeys: string[] = [];
|
||||||
if (name !== undefined) { fields.name = name; bodyKeys.push('name'); }
|
if (name !== undefined) { fields.name = name; bodyKeys.push('name'); }
|
||||||
if (color !== undefined) { fields.color = color; bodyKeys.push('color'); }
|
if (color !== undefined) { fields.color = color; bodyKeys.push('color'); }
|
||||||
const bag = updateBag(tripId, bagId, fields, bodyKeys);
|
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: [] };
|
||||||
safeBroadcast(tripId, 'packing:bag-updated', { bag });
|
safeBroadcast(tripId, 'packing:bag-updated', { bag });
|
||||||
return ok({ bag });
|
return ok({ bag });
|
||||||
}
|
}
|
||||||
@@ -238,9 +244,10 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
|
|||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||||
setBagMembers(tripId, bagId, userIds);
|
const members = setBagMembers(tripId, bagId, userIds);
|
||||||
safeBroadcast(tripId, 'packing:bag-members-updated', { bagId, userIds });
|
if (!members) return { content: [{ type: 'text' as const, text: 'Bag not found.' }], isError: true };
|
||||||
return ok({ success: true });
|
safeBroadcast(tripId, 'packing:bag-members-updated', { bagId, members });
|
||||||
|
return ok({ members });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -275,9 +282,9 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
|
|||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||||
updatePackingCategoryAssignees(tripId, categoryName, userIds);
|
const assignees = updatePackingCategoryAssignees(tripId, categoryName, userIds);
|
||||||
safeBroadcast(tripId, 'packing:assignees', { categoryName, userIds });
|
safeBroadcast(tripId, 'packing:assignees', { category: categoryName, assignees });
|
||||||
return ok({ success: true });
|
return ok({ assignees });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -295,17 +302,32 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
|
|||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||||
const applied = applyTemplate(tripId, templateId);
|
const items = applyTemplate(tripId, templateId);
|
||||||
if (applied === null) return { content: [{ type: 'text' as const, text: 'Template not found.' }], isError: true };
|
if (items === null) return { content: [{ type: 'text' as const, text: 'Template not found.' }], isError: true };
|
||||||
safeBroadcast(tripId, 'packing:template-applied', { templateId });
|
safeBroadcast(tripId, 'packing:template-applied', { items });
|
||||||
return ok({ success: true });
|
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() });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (W) server.registerTool(
|
if (W) server.registerTool(
|
||||||
'save_packing_template',
|
'save_packing_template',
|
||||||
{
|
{
|
||||||
description: 'Save the current packing list as a reusable template.',
|
description: 'Save the current packing list as a reusable template. Returns the new template (id, name, category/item counts). Admin only.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
tripId: z.number().int().positive(),
|
tripId: z.number().int().positive(),
|
||||||
templateName: z.string().min(1).max(100),
|
templateName: z.string().min(1).max(100),
|
||||||
@@ -316,21 +338,46 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
|
|||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||||
saveAsTemplate(tripId, userId, templateName);
|
// Templates are global; the REST route restricts saving to admins. Match it.
|
||||||
return ok({ success: true });
|
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 });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (W) server.registerTool(
|
if (W) server.registerTool(
|
||||||
'bulk_import_packing',
|
'bulk_import_packing',
|
||||||
{
|
{
|
||||||
description: 'Import multiple packing items at once from a list.',
|
description: 'Import multiple packing items at once from a list. Optionally assign each to a bag (by name — created if missing), set its weight, or pre-check it.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
tripId: z.number().int().positive(),
|
tripId: z.number().int().positive(),
|
||||||
items: z.array(z.object({
|
items: z.array(z.object({
|
||||||
name: z.string().min(1).max(200),
|
name: z.string().min(1).max(200),
|
||||||
category: z.string().optional(),
|
category: z.string().optional(),
|
||||||
quantity: z.number().int().positive().optional(),
|
quantity: z.number().int().positive().optional(),
|
||||||
|
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),
|
})).min(1),
|
||||||
},
|
},
|
||||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||||
@@ -339,9 +386,9 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
|
|||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||||
bulkImport(tripId, items);
|
const created = bulkImport(tripId, items);
|
||||||
safeBroadcast(tripId, 'packing:updated', {});
|
for (const item of created) safeBroadcast(tripId, 'packing:created', { item });
|
||||||
return ok({ success: true, count: items.length });
|
return ok({ items: created, count: created.length });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ export function registerReservationTools(server: McpServer, userId: number, scop
|
|||||||
|
|
||||||
safeBroadcast(tripId, isNewAccommodation ? 'accommodation:created' : 'accommodation:updated', {});
|
safeBroadcast(tripId, isNewAccommodation ? 'accommodation:created' : 'accommodation:updated', {});
|
||||||
safeBroadcast(tripId, 'reservation:updated', { reservation });
|
safeBroadcast(tripId, 'reservation:updated', { reservation });
|
||||||
return ok({ reservation, accommodation_id: (reservation as any).accommodation_id });
|
return ok({ reservation, accommodation_id: (reservation as any)?.accommodation_id ?? null });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import { canAccessTrip } from '../../db/database';
|
|||||||
import { isDemoUser } from '../../services/authService';
|
import { isDemoUser } from '../../services/authService';
|
||||||
import {
|
import {
|
||||||
createReservation, deleteReservation, getReservation, updateReservation,
|
createReservation, deleteReservation, getReservation, updateReservation,
|
||||||
|
type EndpointInput,
|
||||||
} from '../../services/reservationService';
|
} from '../../services/reservationService';
|
||||||
import { linkBudgetItemToReservation } from '../../services/budgetService';
|
import { linkBudgetItemToReservation } from '../../services/budgetService';
|
||||||
import { getDay } from '../../services/dayService';
|
import { getDay } from '../../services/dayService';
|
||||||
|
import { findByIata } from '../../services/airportService';
|
||||||
import {
|
import {
|
||||||
safeBroadcast, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
safeBroadcast, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||||
TOOL_ANNOTATIONS_WRITE, demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
|
TOOL_ANNOTATIONS_WRITE, demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
|
||||||
@@ -15,17 +17,56 @@ import { canWrite } from '../scopes';
|
|||||||
|
|
||||||
const TRANSPORT_TYPES = ['flight', 'train', 'car', 'cruise'] as const;
|
const TRANSPORT_TYPES = ['flight', 'train', 'car', 'cruise'] as const;
|
||||||
|
|
||||||
const endpointSchema = z.array(z.object({
|
const endpointObjectSchema = z.object({
|
||||||
role: z.enum(['from', 'to', 'stop']).describe('Endpoint role: "from" (origin), "to" (destination), or "stop" (intermediate)'),
|
role: z.enum(['from', 'to', 'stop']).describe('Endpoint role: "from" (origin), "to" (destination), or "stop" (intermediate)'),
|
||||||
sequence: z.number().int().min(0).describe('Order within the route (0-based)'),
|
sequence: z.number().int().min(0).describe('Order within the route (0-based)'),
|
||||||
name: z.string().min(1).describe('Location name (e.g. "Paris Gare de Lyon", "ZRH Terminal 2")'),
|
name: z.string().min(1).describe('Location name (e.g. "Paris Gare de Lyon", "ZRH Terminal 2")'),
|
||||||
code: z.string().optional().describe('IATA airport code for flights (e.g. "ZRH"). Leave empty for other transport types.'),
|
code: z.string().optional().describe('IATA airport code for flights (e.g. "ZRH"). Leave empty for other transport types.'),
|
||||||
lat: z.number().optional(),
|
lat: z.number().optional().describe('Latitude. For flights, leave empty and set code instead — coordinates are filled from the airport.'),
|
||||||
lng: z.number().optional(),
|
lng: z.number().optional().describe('Longitude. For flights, leave empty and set code instead — coordinates are filled from the airport.'),
|
||||||
timezone: z.string().optional().describe('IANA timezone (e.g. "Europe/Zurich"). Use airport tz for flights.'),
|
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_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'),
|
local_date: z.string().optional().describe('Local date at this endpoint, YYYY-MM-DD'),
|
||||||
})).optional();
|
});
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
export function registerTransportTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
export function registerTransportTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
||||||
if (!canWrite(scopes, 'reservations')) return;
|
if (!canWrite(scopes, 'reservations')) return;
|
||||||
@@ -63,6 +104,9 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
|
|||||||
if (end_day_id && !getDay(end_day_id, tripId))
|
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 };
|
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 ?? {}) };
|
const meta: Record<string, string> = { ...(metadata ?? {}) };
|
||||||
if (price != null) meta.price = String(price);
|
if (price != null) meta.price = String(price);
|
||||||
|
|
||||||
@@ -78,7 +122,7 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
|
|||||||
end_day_id: end_day_id ?? start_day_id,
|
end_day_id: end_day_id ?? start_day_id,
|
||||||
status: status ?? 'pending',
|
status: status ?? 'pending',
|
||||||
metadata: Object.keys(meta).length > 0 ? meta : undefined,
|
metadata: Object.keys(meta).length > 0 ? meta : undefined,
|
||||||
endpoints,
|
endpoints: resolved.endpoints,
|
||||||
needs_review,
|
needs_review,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -135,6 +179,14 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
|
|||||||
if (end_day_id && !getDay(end_day_id, tripId))
|
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 };
|
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, {
|
const { reservation } = updateReservation(reservationId, tripId, {
|
||||||
title,
|
title,
|
||||||
type,
|
type,
|
||||||
@@ -146,7 +198,7 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
|
|||||||
end_day_id,
|
end_day_id,
|
||||||
status,
|
status,
|
||||||
metadata,
|
metadata,
|
||||||
endpoints,
|
endpoints: resolvedEndpoints,
|
||||||
needs_review,
|
needs_review,
|
||||||
}, existing);
|
}, existing);
|
||||||
safeBroadcast(tripId, 'reservation:updated', { reservation });
|
safeBroadcast(tripId, 'reservation:updated', { reservation });
|
||||||
|
|||||||
@@ -55,8 +55,10 @@ export function registerVacayTools(server: McpServer, userId: number, scopes: st
|
|||||||
async ({ block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled }) => {
|
async ({ block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled }) => {
|
||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
const planId = getActivePlanId(userId);
|
const planId = getActivePlanId(userId);
|
||||||
await updatePlan(planId, { block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled }, undefined);
|
// updatePlan already returns the fully-hydrated { plan }; surface it so the
|
||||||
return ok({ success: true });
|
// 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);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -73,7 +75,8 @@ export function registerVacayTools(server: McpServer, userId: number, scopes: st
|
|||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
const planId = getActivePlanId(userId);
|
const planId = getActivePlanId(userId);
|
||||||
setUserColor(userId, planId, color, undefined);
|
setUserColor(userId, planId, color, undefined);
|
||||||
return ok({ success: true });
|
// Echo the persisted color (mirrors the service default) so the AI consumer sees what was set.
|
||||||
|
return ok({ success: true, color: color || '#6366f1' });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,31 @@ export class BudgetController {
|
|||||||
return { settlement };
|
return { settlement };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Put('settlements/:settlementId')
|
||||||
|
updateSettlement(
|
||||||
|
@CurrentUser() user: User,
|
||||||
|
@Param('tripId') tripId: string,
|
||||||
|
@Param('settlementId') settlementId: string,
|
||||||
|
@Body() body: { from_user_id?: number; to_user_id?: number; amount?: number },
|
||||||
|
@Headers('x-socket-id') socketId?: string,
|
||||||
|
) {
|
||||||
|
const trip = this.requireTrip(tripId, user);
|
||||||
|
this.requireEdit(trip, user);
|
||||||
|
if (body.from_user_id == null || body.to_user_id == null || body.amount == null) {
|
||||||
|
throw new HttpException({ error: 'from_user_id, to_user_id and amount are required' }, 400);
|
||||||
|
}
|
||||||
|
const settlement = this.budget.updateSettlement(settlementId, tripId, {
|
||||||
|
from_user_id: body.from_user_id,
|
||||||
|
to_user_id: body.to_user_id,
|
||||||
|
amount: body.amount,
|
||||||
|
});
|
||||||
|
if (!settlement) {
|
||||||
|
throw new HttpException({ error: 'Settlement not found' }, 404);
|
||||||
|
}
|
||||||
|
this.budget.broadcast(tripId, 'budget:settlement-updated', { settlement }, socketId);
|
||||||
|
return { settlement };
|
||||||
|
}
|
||||||
|
|
||||||
@Delete('settlements/:settlementId')
|
@Delete('settlements/:settlementId')
|
||||||
deleteSettlement(
|
deleteSettlement(
|
||||||
@CurrentUser() user: User,
|
@CurrentUser() user: User,
|
||||||
@@ -114,7 +139,7 @@ export class BudgetController {
|
|||||||
create(
|
create(
|
||||||
@CurrentUser() user: User,
|
@CurrentUser() user: User,
|
||||||
@Param('tripId') tripId: string,
|
@Param('tripId') tripId: string,
|
||||||
@Body() body: { name?: string; category?: string; total_price?: number; persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null },
|
@Body() body: { name?: string; category?: string; total_price?: number; persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null; reservation_id?: number },
|
||||||
@Headers('x-socket-id') socketId?: string,
|
@Headers('x-socket-id') socketId?: string,
|
||||||
) {
|
) {
|
||||||
const trip = this.requireTrip(tripId, user);
|
const trip = this.requireTrip(tripId, user);
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ export class BudgetService {
|
|||||||
return svc.createSettlement(tripId, data, userId);
|
return svc.createSettlement(tripId, data, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateSettlement(id: string, tripId: string, data: { from_user_id: number; to_user_id: number; amount: number }) {
|
||||||
|
return svc.updateSettlement(id, tripId, data);
|
||||||
|
}
|
||||||
|
|
||||||
deleteSettlement(id: string, tripId: string): boolean {
|
deleteSettlement(id: string, tripId: string): boolean {
|
||||||
return svc.deleteSettlement(id, tripId);
|
return svc.deleteSettlement(id, tripId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export class AirtrailController {
|
|||||||
body.url,
|
body.url,
|
||||||
body.apiKey,
|
body.apiKey,
|
||||||
!!body.allowInsecureTls,
|
!!body.allowInsecureTls,
|
||||||
|
!!body.writeEnabled,
|
||||||
getClientIp(req),
|
getClientIp(req),
|
||||||
);
|
);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { checkPermission } from '../../services/permissions';
|
|||||||
import type { User } from '../../types';
|
import type { User } from '../../types';
|
||||||
import * as svc from '../../services/reservationService';
|
import * as svc from '../../services/reservationService';
|
||||||
import { createBudgetItem, updateBudgetItem, deleteBudgetItem, linkBudgetItemToReservation } from '../../services/budgetService';
|
import { createBudgetItem, updateBudgetItem, deleteBudgetItem, linkBudgetItemToReservation } from '../../services/budgetService';
|
||||||
|
import { typeToCostCategory } from '@trek/shared';
|
||||||
|
|
||||||
type Trip = NonNullable<ReturnType<typeof svc.verifyTripAccess>>;
|
type Trip = NonNullable<ReturnType<typeof svc.verifyTripAccess>>;
|
||||||
type BudgetEntry = { total_price?: number; category?: string } | undefined;
|
type BudgetEntry = { total_price?: number; category?: string } | undefined;
|
||||||
@@ -77,30 +78,51 @@ export class ReservationsService {
|
|||||||
|
|
||||||
/** PUT side effect: drop the linked budget item when the price is cleared, else create/update it. */
|
/** PUT side effect: drop the linked budget item when the price is cleared, else create/update it. */
|
||||||
syncBudgetOnUpdate(tripId: string, id: string, title: string, type: string | undefined, currentTitle: string, currentType: string | undefined, entry: BudgetEntry, socketId: string | undefined): void {
|
syncBudgetOnUpdate(tripId: string, id: string, title: string, type: string | undefined, currentTitle: string, currentType: string | undefined, entry: BudgetEntry, socketId: string | undefined): void {
|
||||||
if (!entry || !entry.total_price) {
|
// When the booking type changes, keep a linked expense's category in sync —
|
||||||
|
// but only if it still carries the auto-derived category (so a manual pick in
|
||||||
|
// the Costs editor is preserved). Runs regardless of create_budget_entry.
|
||||||
|
if (type && currentType && type !== currentType) {
|
||||||
|
const linked = db.prepare('SELECT id, category FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number; category: string } | undefined;
|
||||||
|
if (linked) {
|
||||||
|
const oldCat = typeToCostCategory(currentType);
|
||||||
|
const newCat = typeToCostCategory(type);
|
||||||
|
if (oldCat !== newCat && linked.category === oldCat) {
|
||||||
|
const updated = updateBudgetItem(linked.id, tripId, { category: newCat });
|
||||||
|
broadcast(tripId, 'budget:updated', { item: updated }, socketId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No budget entry on the payload — the booking edit isn't touching its linked
|
||||||
|
// expense, so leave any linked item alone. Expenses are managed from the
|
||||||
|
// booking's Costs section / the Costs tab, not by re-saving the booking.
|
||||||
|
if (!entry) return;
|
||||||
|
|
||||||
|
if (!(Number(entry.total_price) > 0)) {
|
||||||
|
// Explicit clear (total_price 0/empty) — drop the linked item.
|
||||||
const linked = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined;
|
const linked = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined;
|
||||||
if (linked) {
|
if (linked) {
|
||||||
deleteBudgetItem(linked.id, tripId);
|
deleteBudgetItem(linked.id, tripId);
|
||||||
broadcast(tripId, 'budget:deleted', { itemId: linked.id }, socketId);
|
broadcast(tripId, 'budget:deleted', { itemId: linked.id }, socketId);
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (entry && Number(entry.total_price) > 0) {
|
|
||||||
try {
|
try {
|
||||||
const itemName = title || currentTitle;
|
const itemName = title || currentTitle;
|
||||||
const category = entry.category || type || currentType || 'Other';
|
const category = entry.category || type || currentType || 'Other';
|
||||||
const existing = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined;
|
const existing = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined;
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const updated = updateBudgetItem(existing.id, tripId, { name: itemName, category, total_price: entry.total_price });
|
const updated = updateBudgetItem(existing.id, tripId, { name: itemName, category, total_price: entry.total_price });
|
||||||
broadcast(tripId, 'budget:updated', { item: updated }, socketId);
|
broadcast(tripId, 'budget:updated', { item: updated }, socketId);
|
||||||
} else {
|
} else {
|
||||||
const item = createBudgetItem(tripId, { name: itemName, category, total_price: entry.total_price });
|
const item = createBudgetItem(tripId, { name: itemName, category, total_price: entry.total_price });
|
||||||
db.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(id, item.id);
|
db.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(id, item.id);
|
||||||
item.reservation_id = Number(id);
|
item.reservation_id = Number(id);
|
||||||
broadcast(tripId, 'budget:created', { item }, socketId);
|
broadcast(tripId, 'budget:created', { item }, socketId);
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[reservations] Failed to create/update budget entry:', err);
|
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[reservations] Failed to create/update budget entry:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ export interface AirtrailFlightRaw {
|
|||||||
datePrecision: string | null;
|
datePrecision: string | null;
|
||||||
departure: string | null;
|
departure: string | null;
|
||||||
arrival: string | null;
|
arrival: string | null;
|
||||||
|
departureScheduled: string | null;
|
||||||
|
arrivalScheduled: string | null;
|
||||||
airline: AirtrailNamedCode | null;
|
airline: AirtrailNamedCode | null;
|
||||||
flightNumber: string | null;
|
flightNumber: string | null;
|
||||||
aircraft: AirtrailNamedCode | null;
|
aircraft: AirtrailNamedCode | null;
|
||||||
@@ -92,10 +94,14 @@ export interface AirtrailSavePayload {
|
|||||||
id?: number;
|
id?: number;
|
||||||
from: string;
|
from: string;
|
||||||
to: string;
|
to: string;
|
||||||
departure: string;
|
departure: string | null;
|
||||||
departureTime?: string | null;
|
departureTime?: string | null;
|
||||||
arrival?: string | null;
|
arrival?: string | null;
|
||||||
arrivalTime?: string | null;
|
arrivalTime?: string | null;
|
||||||
|
departureScheduled?: string | null;
|
||||||
|
departureScheduledTime?: string | null;
|
||||||
|
arrivalScheduled?: string | null;
|
||||||
|
arrivalScheduledTime?: string | null;
|
||||||
datePrecision?: string;
|
datePrecision?: string;
|
||||||
airline?: string | null;
|
airline?: string | null;
|
||||||
flightNumber?: string | null;
|
flightNumber?: string | null;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ function airportCode(a: AirtrailAirport | null): string | null {
|
|||||||
* Airline/aircraft arrive as joined objects ({icao, iata, name, ...}); reduce
|
* Airline/aircraft arrive as joined objects ({icao, iata, name, ...}); reduce
|
||||||
* them to a single code (ICAO preferred, matching AirTrail's save shape).
|
* them to a single code (ICAO preferred, matching AirTrail's save shape).
|
||||||
*/
|
*/
|
||||||
function entityCode(e: AirtrailNamedCode | null | undefined): string | null {
|
export function entityCode(e: AirtrailNamedCode | null | undefined): string | null {
|
||||||
return e?.icao || e?.iata || null;
|
return e?.icao || e?.iata || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,8 +55,8 @@ export function normalizeFlight(raw: AirtrailFlightRaw): AirtrailFlight {
|
|||||||
toCode: airportCode(raw.to),
|
toCode: airportCode(raw.to),
|
||||||
toName: raw.to?.name ?? null,
|
toName: raw.to?.name ?? null,
|
||||||
date: raw.date ?? null,
|
date: raw.date ?? null,
|
||||||
departure: raw.departure ?? null,
|
departure: raw.departureScheduled ?? null,
|
||||||
arrival: raw.arrival ?? null,
|
arrival: raw.arrivalScheduled ?? null,
|
||||||
airline: entityCode(raw.airline),
|
airline: entityCode(raw.airline),
|
||||||
flightNumber: raw.flightNumber ?? null,
|
flightNumber: raw.flightNumber ?? null,
|
||||||
aircraft: entityCode(raw.aircraft),
|
aircraft: entityCode(raw.aircraft),
|
||||||
@@ -94,14 +94,17 @@ function hasCoords(a: AirtrailAirport | null): a is AirtrailAirport & { lat: num
|
|||||||
|
|
||||||
/** Raw AirTrail flight → the data createReservation() expects (type:'flight'). */
|
/** Raw AirTrail flight → the data createReservation() expects (type:'flight'). */
|
||||||
export function mapFlightToReservation(raw: AirtrailFlightRaw): MappedReservation {
|
export function mapFlightToReservation(raw: AirtrailFlightRaw): MappedReservation {
|
||||||
const dep = localParts(raw.departure, raw.from?.tz ?? null);
|
// Read the SCHEDULED times only — TREK plans against the scheduled (booked) time,
|
||||||
const arr = localParts(raw.arrival, raw.to?.tz ?? null);
|
// 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 fromCode = airportCode(raw.from);
|
const fromCode = airportCode(raw.from);
|
||||||
const toCode = airportCode(raw.to);
|
const toCode = airportCode(raw.to);
|
||||||
const datePrefix = raw.date || dep.date;
|
const datePrefix = raw.date || dep.date;
|
||||||
const reservation_time = datePrefix ? `${datePrefix}T${dep.time ?? '00:00'}` : null;
|
const reservation_time = dep.date && dep.time ? `${dep.date}T${dep.time}` : (datePrefix ?? null);
|
||||||
const reservation_end_time = arr.date ? `${arr.date}T${arr.time ?? '00:00'}` : null;
|
const reservation_end_time = arr.date && arr.time ? `${arr.date}T${arr.time}` : null;
|
||||||
|
|
||||||
const endpoints: MappedEndpoint[] = [];
|
const endpoints: MappedEndpoint[] = [];
|
||||||
let needsReview = raw.datePrecision && raw.datePrecision !== 'day' ? 1 : 0;
|
let needsReview = raw.datePrecision && raw.datePrecision !== 'day' ? 1 : 0;
|
||||||
@@ -147,7 +150,7 @@ export function mapFlightToReservation(raw: AirtrailFlightRaw): MappedReservatio
|
|||||||
if (aircraftCode) metadata.aircraft = aircraftCode;
|
if (aircraftCode) metadata.aircraft = aircraftCode;
|
||||||
if (raw.aircraftReg) metadata.aircraft_reg = raw.aircraftReg;
|
if (raw.aircraftReg) metadata.aircraft_reg = raw.aircraftReg;
|
||||||
if (raw.flightReason) metadata.flight_reason = raw.flightReason;
|
if (raw.flightReason) metadata.flight_reason = raw.flightReason;
|
||||||
if (seat?.seatNumber || seat?.seatClass) metadata.seat = seat.seatNumber || seat.seatClass;
|
if (seat?.seatNumber) metadata.seat = seat.seatNumber;
|
||||||
|
|
||||||
// The flight number already carries the airline prefix (e.g. "SAS983"), so it
|
// The flight number already carries the airline prefix (e.g. "SAS983"), so it
|
||||||
// makes the clearest title; fall back to the route.
|
// makes the clearest title; fall back to the route.
|
||||||
@@ -178,8 +181,8 @@ export function canonicalHash(raw: AirtrailFlightRaw): string {
|
|||||||
to: airportCode(raw.to),
|
to: airportCode(raw.to),
|
||||||
date: raw.date ?? null,
|
date: raw.date ?? null,
|
||||||
datePrecision: raw.datePrecision ?? 'day',
|
datePrecision: raw.datePrecision ?? 'day',
|
||||||
departure: raw.departure ?? null,
|
departureScheduled: raw.departureScheduled ?? null,
|
||||||
arrival: raw.arrival ?? null,
|
arrivalScheduled: raw.arrivalScheduled ?? null,
|
||||||
airline: entityCode(raw.airline),
|
airline: entityCode(raw.airline),
|
||||||
flightNumber: raw.flightNumber ?? null,
|
flightNumber: raw.flightNumber ?? null,
|
||||||
aircraft: entityCode(raw.aircraft),
|
aircraft: entityCode(raw.aircraft),
|
||||||
|
|||||||
@@ -12,14 +12,25 @@ interface UserConnRow {
|
|||||||
airtrail_url?: string | null;
|
airtrail_url?: string | null;
|
||||||
airtrail_api_key?: string | null;
|
airtrail_api_key?: string | null;
|
||||||
airtrail_allow_insecure_tls?: number | null;
|
airtrail_allow_insecure_tls?: number | null;
|
||||||
|
airtrail_write_enabled?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readRow(userId: number): UserConnRow | undefined {
|
function readRow(userId: number): UserConnRow | undefined {
|
||||||
return db
|
return db
|
||||||
.prepare('SELECT airtrail_url, airtrail_api_key, airtrail_allow_insecure_tls FROM users WHERE id = ?')
|
.prepare(
|
||||||
|
'SELECT airtrail_url, airtrail_api_key, airtrail_allow_insecure_tls, airtrail_write_enabled FROM users WHERE id = ?',
|
||||||
|
)
|
||||||
.get(userId) as UserConnRow | undefined;
|
.get(userId) as UserConnRow | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Has this user opted in to TREK writing their flight edits back to AirTrail? (#1240) */
|
||||||
|
export function isAirtrailWriteEnabled(userId: number): boolean {
|
||||||
|
const row = db.prepare('SELECT airtrail_write_enabled FROM users WHERE id = ?').get(userId) as
|
||||||
|
| { airtrail_write_enabled?: number | null }
|
||||||
|
| undefined;
|
||||||
|
return !!row?.airtrail_write_enabled;
|
||||||
|
}
|
||||||
|
|
||||||
/** Decrypted creds for outbound calls, or null when the user has no connection. */
|
/** Decrypted creds for outbound calls, or null when the user has no connection. */
|
||||||
export function getAirtrailCredentials(userId: number): AirtrailCreds | null {
|
export function getAirtrailCredentials(userId: number): AirtrailCreds | null {
|
||||||
const row = readRow(userId);
|
const row = readRow(userId);
|
||||||
@@ -40,6 +51,7 @@ export function getConnectionSettings(userId: number) {
|
|||||||
url: row?.airtrail_url || '',
|
url: row?.airtrail_url || '',
|
||||||
apiKeyMasked: row?.airtrail_api_key ? KEY_MASK : '',
|
apiKeyMasked: row?.airtrail_api_key ? KEY_MASK : '',
|
||||||
allowInsecureTls: !!row?.airtrail_allow_insecure_tls,
|
allowInsecureTls: !!row?.airtrail_allow_insecure_tls,
|
||||||
|
writeEnabled: !!row?.airtrail_write_enabled,
|
||||||
connected: !!(row?.airtrail_url && row?.airtrail_api_key),
|
connected: !!(row?.airtrail_url && row?.airtrail_api_key),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -49,6 +61,7 @@ export async function saveSettings(
|
|||||||
url: string | undefined,
|
url: string | undefined,
|
||||||
apiKey: string | undefined,
|
apiKey: string | undefined,
|
||||||
allowInsecureTls: boolean,
|
allowInsecureTls: boolean,
|
||||||
|
writeEnabled: boolean,
|
||||||
clientIp: string | null,
|
clientIp: string | null,
|
||||||
): Promise<{ success: boolean; warning?: string; error?: string }> {
|
): Promise<{ success: boolean; warning?: string; error?: string }> {
|
||||||
const trimmedUrl = (url || '').trim();
|
const trimmedUrl = (url || '').trim();
|
||||||
@@ -81,12 +94,12 @@ export async function saveSettings(
|
|||||||
|
|
||||||
if (newKey !== undefined) {
|
if (newKey !== undefined) {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
'UPDATE users SET airtrail_url = ?, airtrail_api_key = ?, airtrail_allow_insecure_tls = ? WHERE id = ?',
|
'UPDATE users SET airtrail_url = ?, airtrail_api_key = ?, airtrail_allow_insecure_tls = ?, airtrail_write_enabled = ? WHERE id = ?',
|
||||||
).run(trimmedUrl || null, newKey, allowInsecureTls ? 1 : 0, userId);
|
).run(trimmedUrl || null, newKey, allowInsecureTls ? 1 : 0, writeEnabled ? 1 : 0, userId);
|
||||||
} else {
|
} else {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
'UPDATE users SET airtrail_url = ?, airtrail_allow_insecure_tls = ? WHERE id = ?',
|
'UPDATE users SET airtrail_url = ?, airtrail_allow_insecure_tls = ?, airtrail_write_enabled = ? WHERE id = ?',
|
||||||
).run(trimmedUrl || null, allowInsecureTls ? 1 : 0, userId);
|
).run(trimmedUrl || null, allowInsecureTls ? 1 : 0, writeEnabled ? 1 : 0, userId);
|
||||||
// Clearing the URL with no key left makes the connection meaningless — drop the key too.
|
// Clearing the URL with no key left makes the connection meaningless — drop the key too.
|
||||||
if (!trimmedUrl) {
|
if (!trimmedUrl) {
|
||||||
db.prepare('UPDATE users SET airtrail_api_key = NULL WHERE id = ?').run(userId);
|
db.prepare('UPDATE users SET airtrail_api_key = NULL WHERE id = ?').run(userId);
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import {
|
|||||||
listFlights,
|
listFlights,
|
||||||
saveFlight,
|
saveFlight,
|
||||||
} from './airtrailClient';
|
} from './airtrailClient';
|
||||||
import { canonicalHash, mapFlightToReservation } from './airtrailMapper';
|
import { canonicalHash, entityCode, mapFlightToReservation } from './airtrailMapper';
|
||||||
import { getAirtrailCredentials } from './airtrailService';
|
import { getAirtrailCredentials, isAirtrailWriteEnabled } from './airtrailService';
|
||||||
|
|
||||||
/** Global on/off: the addon must be enabled and sync not explicitly turned off. */
|
/** Global on/off: the addon must be enabled and sync not explicitly turned off. */
|
||||||
export function syncGloballyEnabled(): boolean {
|
export function syncGloballyEnabled(): boolean {
|
||||||
@@ -144,7 +144,16 @@ function splitLocal(dt: string | null | undefined): { date: string | null; time:
|
|||||||
return { date: /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null, time: m ? m[1] : null };
|
return { date: /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null, time: m ? m[1] : null };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSavePayload(reservation: any, existing: AirtrailFlightRaw): AirtrailSavePayload | null {
|
/**
|
||||||
|
* Build the POST /flight/save body. AirTrail's save fully overwrites the flight,
|
||||||
|
* so we start from the flight as AirTrail currently has it (`existing`, the raw
|
||||||
|
* GET object) and overwrite ONLY the fields TREK manages. Everything else —
|
||||||
|
* terminal, gate, scheduled/actual times, customFields, track, and any field
|
||||||
|
* AirTrail may add later — passes through untouched. We deliberately do NOT model
|
||||||
|
* those fields; spreading the raw object keeps us decoupled from AirTrail's schema
|
||||||
|
* (#1240).
|
||||||
|
*/
|
||||||
|
export function buildSavePayload(reservation: any, existing: AirtrailFlightRaw): AirtrailSavePayload | null {
|
||||||
let meta: Record<string, any>;
|
let meta: Record<string, any>;
|
||||||
try {
|
try {
|
||||||
meta = reservation.metadata ? JSON.parse(reservation.metadata) : {};
|
meta = reservation.metadata ? JSON.parse(reservation.metadata) : {};
|
||||||
@@ -183,7 +192,14 @@ function buildSavePayload(reservation: any, existing: AirtrailFlightRaw): Airtra
|
|||||||
if (ownSeat) ownSeat.seatNumber = seatNumber;
|
if (ownSeat) ownSeat.seatNumber = seatNumber;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Spread the existing flight first to preserve every AirTrail-owned field, then
|
||||||
|
// overwrite only what TREK manages. `from`/`to`/`airline`/`aircraft` come back
|
||||||
|
// from GET as objects but the save shape wants codes — those are exactly the
|
||||||
|
// keys we override, so the spread never ships an object where a code is wanted.
|
||||||
return {
|
return {
|
||||||
|
// Cast so the spread carries through the AirTrail-owned keys we deliberately
|
||||||
|
// don't model (terminal, gate, scheduled/actual times, customFields, track, …).
|
||||||
|
...(existing as unknown as Record<string, unknown>),
|
||||||
id: Number(reservation.external_id),
|
id: Number(reservation.external_id),
|
||||||
from: fromCode,
|
from: fromCode,
|
||||||
to: toCode,
|
to: toCode,
|
||||||
@@ -191,14 +207,25 @@ function buildSavePayload(reservation: any, existing: AirtrailFlightRaw): Airtra
|
|||||||
departureTime: dep.time,
|
departureTime: dep.time,
|
||||||
arrival: arr.date,
|
arrival: arr.date,
|
||||||
arrivalTime: arr.time,
|
arrivalTime: arr.time,
|
||||||
airline: meta.airline ?? null,
|
// Import reads the SCHEDULED time, so a TREK edit must write back there too —
|
||||||
flightNumber: meta.flight_number ?? null,
|
// otherwise the next pull (scheduled-wins) would revert it. AirTrail rebuilds the
|
||||||
aircraft: meta.aircraft ?? null,
|
// instant from a full-ISO date carrier + the HH:MM time, so pass a date carrier.
|
||||||
aircraftReg: meta.aircraft_reg ?? null,
|
departureScheduled: dep.date ? `${dep.date}T00:00:00.000Z` : null,
|
||||||
flightReason: meta.flight_reason ?? null,
|
departureScheduledTime: dep.time,
|
||||||
note: reservation.notes ?? null,
|
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
|
||||||
|
// import/hash code-selection so a writeback stays a no-op for the hash.
|
||||||
|
airline: meta.airline ?? entityCode(existing.airline) ?? null,
|
||||||
|
flightNumber: meta.flight_number ?? existing.flightNumber ?? null,
|
||||||
|
aircraft: meta.aircraft ?? entityCode(existing.aircraft) ?? null,
|
||||||
|
aircraftReg: meta.aircraft_reg ?? existing.aircraftReg ?? null,
|
||||||
|
flightReason: meta.flight_reason ?? existing.flightReason ?? null,
|
||||||
|
note: reservation.notes ?? existing.note ?? null,
|
||||||
seats,
|
seats,
|
||||||
};
|
} as AirtrailSavePayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -219,9 +246,12 @@ export async function pushReservationToAirtrail(reservationId: number, tripId: n
|
|||||||
| undefined;
|
| undefined;
|
||||||
if (!row || !row.sync_enabled) return;
|
if (!row || !row.sync_enabled) return;
|
||||||
|
|
||||||
const creds: AirtrailCreds | null = row.external_owner_user_id
|
// AirTrail is read-only by default (#1240). Only push when the flight's owner has
|
||||||
? getAirtrailCredentials(row.external_owner_user_id)
|
// explicitly opted in. A no-op skip (not a detach): the link stays active so the
|
||||||
: null;
|
// inbound, AirTrail-wins pull keeps the reservation up to date.
|
||||||
|
if (!row.external_owner_user_id || !isAirtrailWriteEnabled(row.external_owner_user_id)) return;
|
||||||
|
|
||||||
|
const creds: AirtrailCreds | null = getAirtrailCredentials(row.external_owner_user_id);
|
||||||
if (!creds) {
|
if (!creds) {
|
||||||
detach(tripId, row.id); // owner disconnected — cannot push, so stop syncing
|
detach(tripId, row.id); // owner disconnected — cannot push, so stop syncing
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import path from 'path';
|
|||||||
import zlib from 'zlib';
|
import zlib from 'zlib';
|
||||||
import { db } from '../db/database';
|
import { db } from '../db/database';
|
||||||
import { Trip, Place } from '../types';
|
import { Trip, Place } from '../types';
|
||||||
|
import { CONTINENT_MAP } from '@trek/shared';
|
||||||
|
|
||||||
// ── Bundled boundary GeoJSON (admin-0 countries + admin-1 regions) ─────────
|
// ── Bundled boundary GeoJSON (admin-0 countries + admin-1 regions) ─────────
|
||||||
//
|
//
|
||||||
@@ -168,30 +169,6 @@ export const NAME_TO_CODE: Record<string, string> = {
|
|||||||
'liechtenstein':'LI','gibraltar':'GI','puerto rico':'PR',
|
'liechtenstein':'LI','gibraltar':'GI','puerto rico':'PR',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CONTINENT_MAP: Record<string, string> = {
|
|
||||||
AF:'Asia',AL:'Europe',DZ:'Africa',AD:'Europe',AO:'Africa',AR:'South America',AM:'Asia',AU:'Oceania',AT:'Europe',AZ:'Asia',
|
|
||||||
BA:'Europe',BD:'Asia',BF:'Africa',BH:'Asia',BI:'Africa',BJ:'Africa',BN:'Asia',BO:'South America',
|
|
||||||
BR:'South America',BE:'Europe',BG:'Europe',BW:'Africa',
|
|
||||||
CA:'North America',CD:'Africa',CG:'Africa',CI:'Africa',CL:'South America',CM:'Africa',CN:'Asia',CO:'South America',
|
|
||||||
CR:'North America',CU:'North America',CV:'Africa',CY:'Europe',HR:'Europe',CZ:'Europe',
|
|
||||||
DJ:'Africa',DK:'Europe',DO:'North America',EC:'South America',EG:'Africa',EE:'Europe',ER:'Africa',ET:'Africa',
|
|
||||||
FI:'Europe',FR:'Europe',DE:'Europe',GE:'Asia',GH:'Africa',GN:'Africa',GR:'Europe',GT:'North America',
|
|
||||||
HN:'North America',HT:'North America',HU:'Europe',IS:'Europe',IN:'Asia',ID:'Asia',IR:'Asia',IQ:'Asia',
|
|
||||||
IE:'Europe',IL:'Asia',IT:'Europe',JM:'North America',JO:'Asia',JP:'Asia',KE:'Africa',KG:'Asia',KH:'Asia',
|
|
||||||
KR:'Asia',KW:'Asia',KZ:'Asia',LA:'Asia',LB:'Asia',LK:'Asia',LV:'Europe',LT:'Europe',LU:'Europe',LY:'Africa',
|
|
||||||
MA:'Africa',MD:'Europe',ME:'Europe',MG:'Africa',MK:'Europe',ML:'Africa',MM:'Asia',MN:'Asia',MR:'Africa',
|
|
||||||
MT:'Europe',MU:'Africa',MV:'Asia',MW:'Africa',MY:'Asia',MX:'North America',MZ:'Africa',
|
|
||||||
NA:'Africa',NE:'Africa',NI:'North America',NL:'Europe',NP:'Asia',NZ:'Oceania',NO:'Europe',OM:'Asia',
|
|
||||||
PA:'North America',PG:'Oceania',PK:'Asia',PE:'South America',PH:'Asia',PL:'Europe',PS:'Asia',
|
|
||||||
PT:'Europe',PY:'South America',QA:'Asia',RO:'Europe',RU:'Europe',RW:'Africa',SA:'Asia',SC:'Africa',
|
|
||||||
SD:'Africa',SG:'Asia',SI:'Europe',SK:'Europe',SN:'Africa',SO:'Africa',RS:'Europe',SV:'North America',
|
|
||||||
SY:'Asia',TG:'Africa',TJ:'Asia',TM:'Asia',TN:'Africa',TT:'North America',TW:'Asia',TZ:'Africa',
|
|
||||||
ZA:'Africa',SE:'Europe',CH:'Europe',TH:'Asia',TR:'Europe',UA:'Europe',UG:'Africa',UY:'South America',
|
|
||||||
UZ:'Asia',VE:'South America',AE:'Asia',GB:'Europe',US:'North America',VN:'Asia',XK:'Europe',
|
|
||||||
YE:'Asia',ZM:'Africa',ZW:'Africa',NG:'Africa',
|
|
||||||
HK:'Asia',MO:'Asia',SM:'Europe',VA:'Europe',MC:'Europe',LI:'Europe',GI:'Europe',PR:'North America',
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Geocoding helpers ───────────────────────────────────────────────────────
|
// ── Geocoding helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
let lastNominatimCall = 0;
|
let lastNominatimCall = 0;
|
||||||
|
|||||||
@@ -15,7 +15,21 @@ const dataDir = path.join(__dirname, '../../data');
|
|||||||
const backupsDir = path.join(dataDir, 'backups');
|
const backupsDir = path.join(dataDir, 'backups');
|
||||||
const uploadsDir = path.join(__dirname, '../../uploads');
|
const uploadsDir = path.join(__dirname, '../../uploads');
|
||||||
|
|
||||||
export const MAX_BACKUP_UPLOAD_SIZE = 500 * 1024 * 1024; // 500 MB compressed
|
// Compressed upload cap for restore archives. Defaults to 500 MB, raisable via
|
||||||
|
// BACKUP_UPLOAD_LIMIT_MB for instances whose backups (uploads/ included) grow
|
||||||
|
// past that. Invalid values warn and fall back to the default.
|
||||||
|
const DEFAULT_BACKUP_UPLOAD_LIMIT_MB = 500;
|
||||||
|
const rawBackupUploadLimit = process.env.BACKUP_UPLOAD_LIMIT_MB?.trim();
|
||||||
|
let backupUploadLimitMb = DEFAULT_BACKUP_UPLOAD_LIMIT_MB;
|
||||||
|
if (rawBackupUploadLimit) {
|
||||||
|
const parsed = Number(rawBackupUploadLimit);
|
||||||
|
if (Number.isFinite(parsed) && parsed > 0) {
|
||||||
|
backupUploadLimitMb = parsed;
|
||||||
|
} else {
|
||||||
|
console.warn(`BACKUP_UPLOAD_LIMIT_MB="${rawBackupUploadLimit}" is not a positive number. Falling back to ${DEFAULT_BACKUP_UPLOAD_LIMIT_MB} MB.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export const MAX_BACKUP_UPLOAD_SIZE = backupUploadLimitMb * 1024 * 1024; // compressed
|
||||||
// Upper bound on the TOTAL decompressed size of a restore archive (the upload
|
// Upper bound on the TOTAL decompressed size of a restore archive (the upload
|
||||||
// limit only caps the compressed bytes). Generous enough for any real backup.
|
// limit only caps the compressed bytes). Generous enough for any real backup.
|
||||||
export const MAX_BACKUP_DECOMPRESSED_SIZE = 5 * 1024 * 1024 * 1024; // 5 GB
|
export const MAX_BACKUP_DECOMPRESSED_SIZE = 5 * 1024 * 1024 * 1024; // 5 GB
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ export function createBudgetItem(
|
|||||||
currency?: string | null; exchange_rate?: number;
|
currency?: string | null; exchange_rate?: number;
|
||||||
payers?: { user_id: number; amount: number }[]; member_ids?: number[];
|
payers?: { user_id: number; amount: number }[]; member_ids?: number[];
|
||||||
persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null;
|
persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null;
|
||||||
|
reservation_id?: number | null;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const maxOrder = db.prepare(
|
const maxOrder = db.prepare(
|
||||||
@@ -128,7 +129,7 @@ export function createBudgetItem(
|
|||||||
const total = data.payers && data.payers.length > 0 ? payerTotal : (data.total_price || 0);
|
const total = data.payers && data.payers.length > 0 ? payerTotal : (data.total_price || 0);
|
||||||
|
|
||||||
const result = db.prepare(
|
const result = db.prepare(
|
||||||
'INSERT INTO budget_items (trip_id, category, name, total_price, currency, exchange_rate, persons, days, note, sort_order, expense_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
'INSERT INTO budget_items (trip_id, category, name, total_price, currency, exchange_rate, persons, days, note, sort_order, expense_date, reservation_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
).run(
|
).run(
|
||||||
tripId,
|
tripId,
|
||||||
cat,
|
cat,
|
||||||
@@ -141,6 +142,7 @@ export function createBudgetItem(
|
|||||||
data.note || null,
|
data.note || null,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
data.expense_date || null,
|
data.expense_date || null,
|
||||||
|
data.reservation_id != null ? data.reservation_id : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const itemId = result.lastInsertRowid as number;
|
const itemId = result.lastInsertRowid as number;
|
||||||
@@ -156,6 +158,15 @@ export function createBudgetItem(
|
|||||||
return item;
|
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(
|
export function linkBudgetItemToReservation(
|
||||||
tripId: string | number,
|
tripId: string | number,
|
||||||
reservationId: number,
|
reservationId: number,
|
||||||
@@ -208,7 +219,15 @@ export function updateBudgetItem(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Optional inline payer/member replacement (the edit modal saves all at once).
|
// Optional inline payer/member replacement (the edit modal saves all at once).
|
||||||
if (data.payers !== undefined) writeItemPayers(id, data.payers);
|
if (data.payers !== undefined) {
|
||||||
|
writeItemPayers(id, data.payers);
|
||||||
|
// writeItemPayers derives total_price from the payer sum (0 for no payers).
|
||||||
|
// A "recorded total, nobody assigned" expense clears payers but still carries
|
||||||
|
// an explicit total_price — re-apply it so it isn't clobbered to 0.
|
||||||
|
if (data.payers.length === 0 && data.total_price !== undefined) {
|
||||||
|
db.prepare('UPDATE budget_items SET total_price = ? WHERE id = ?').run(data.total_price, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (data.member_ids !== undefined) {
|
if (data.member_ids !== undefined) {
|
||||||
db.prepare('DELETE FROM budget_item_members WHERE budget_item_id = ?').run(id);
|
db.prepare('DELETE FROM budget_item_members WHERE budget_item_id = ?').run(id);
|
||||||
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0)');
|
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0)');
|
||||||
@@ -375,11 +394,18 @@ export function calculateSettlement(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Persisted settle-up transfers already moved money: the payer's debt shrinks,
|
// Persisted settle-up transfers already moved money: the payer's debt shrinks,
|
||||||
// the receiver's credit shrinks, so the corresponding flow disappears.
|
// the receiver's credit shrinks, so the corresponding flow disappears. A transfer
|
||||||
|
// counts even when neither user has an expense-derived balance yet — a manual
|
||||||
|
// payment, or one left behind after its expense was deleted, then correctly
|
||||||
|
// surfaces as an amount still to square up instead of silently vanishing.
|
||||||
const settlements = listSettlements(tripId);
|
const settlements = listSettlements(tripId);
|
||||||
|
const ensureSettled = (id: number, username: string | undefined, avatar_url: string | null | undefined) => {
|
||||||
|
if (!balances[id]) balances[id] = { user_id: id, username: username || '', avatar_url: avatar_url ?? null, balance: 0 };
|
||||||
|
return balances[id];
|
||||||
|
};
|
||||||
for (const s of settlements) {
|
for (const s of settlements) {
|
||||||
if (balances[s.from_user_id]) balances[s.from_user_id].balance += s.amount;
|
ensureSettled(s.from_user_id, s.from_username, s.from_avatar_url).balance += s.amount;
|
||||||
if (balances[s.to_user_id]) balances[s.to_user_id].balance -= s.amount;
|
ensureSettled(s.to_user_id, s.to_username, s.to_avatar_url).balance -= s.amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate optimized payment flows (greedy algorithm)
|
// Calculate optimized payment flows (greedy algorithm)
|
||||||
@@ -451,6 +477,19 @@ export function createSettlement(
|
|||||||
return listSettlements(tripId).find(s => s.id === Number(result.lastInsertRowid)) || null;
|
return listSettlements(tripId).find(s => s.id === Number(result.lastInsertRowid)) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateSettlement(
|
||||||
|
id: string | number,
|
||||||
|
tripId: string | number,
|
||||||
|
data: { from_user_id: number; to_user_id: number; amount: number },
|
||||||
|
) {
|
||||||
|
const row = db.prepare('SELECT id FROM budget_settlements WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||||
|
if (!row) return null;
|
||||||
|
db.prepare(
|
||||||
|
'UPDATE budget_settlements SET from_user_id = ?, to_user_id = ?, amount = ? WHERE id = ?'
|
||||||
|
).run(data.from_user_id, data.to_user_id, Math.round(data.amount * 100) / 100, id);
|
||||||
|
return listSettlements(tripId).find(s => s.id === Number(id)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
export function deleteSettlement(id: string | number, tripId: string | number): boolean {
|
export function deleteSettlement(id: string | number, tripId: string | number): boolean {
|
||||||
const row = db.prepare('SELECT id FROM budget_settlements WHERE id = ? AND trip_id = ?').get(id, tripId);
|
const row = db.prepare('SELECT id FROM budget_settlements WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||||
if (!row) return false;
|
if (!row) return false;
|
||||||
|
|||||||
@@ -229,12 +229,17 @@ export function getPollWithVotes(pollId: number | bigint | string) {
|
|||||||
WHERE v.poll_id = ?
|
WHERE v.poll_id = ?
|
||||||
`).all(pollId) as PollVoteRow[];
|
`).all(pollId) as PollVoteRow[];
|
||||||
|
|
||||||
const formattedOptions = options.map((label: string | { label: string }, idx: number) => ({
|
const formattedOptions = options.map((label: string | { label: string }, idx: number) => {
|
||||||
label: typeof label === 'string' ? label : label.label || label,
|
const text = typeof label === 'string' ? label : label.label || label;
|
||||||
voters: votes
|
return {
|
||||||
.filter(v => v.option_index === idx)
|
// The client renders `opt.text`; keep `label` too for any other consumer.
|
||||||
.map(v => ({ id: v.user_id, user_id: v.user_id, username: v.username, avatar: v.avatar, avatar_url: avatarUrl(v) })),
|
text,
|
||||||
}));
|
label: text,
|
||||||
|
voters: votes
|
||||||
|
.filter(v => v.option_index === idx)
|
||||||
|
.map(v => ({ id: v.user_id, user_id: v.user_id, username: v.username, avatar: v.avatar, avatar_url: avatarUrl(v) })),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...poll,
|
...poll,
|
||||||
|
|||||||
@@ -986,59 +986,73 @@ 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 }> {
|
export async function resolveGoogleMapsUrl(url: string): Promise<{ lat: number; lng: number; name: string | null; address: string | null }> {
|
||||||
let resolvedUrl = url;
|
let resolvedUrl = url;
|
||||||
|
|
||||||
// Follow redirects for short URLs (goo.gl, maps.app.goo.gl) with SSRF protection.
|
// Extract coordinates from a string (URL or page body). Google Maps encodes
|
||||||
// Redirects are followed manually so every hop is re-checked — a short link
|
// them several ways: /@lat,lng,zoom · !3dlat!4dlng (map data param) · ?q=/?ll=.
|
||||||
// that 302s to an internal IP is blocked, while a legitimate cross-host
|
const extractCoords = (s: string): { lat: number; lng: number } | null => {
|
||||||
// redirect (goo.gl → maps.google.com) still resolves.
|
const at = s.match(/@(-?\d+\.\d+),(-?\d+\.\d+)/);
|
||||||
const parsed = new URL(url);
|
if (at) return { lat: parseFloat(at[1]), lng: parseFloat(at[2]) };
|
||||||
if (['goo.gl', 'maps.app.goo.gl'].includes(parsed.hostname)) {
|
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> => {
|
||||||
try {
|
try {
|
||||||
const redirectRes = await safeFetchFollow(
|
return await safeFetchFollow(
|
||||||
url,
|
target,
|
||||||
{ signal: AbortSignal.timeout(10000) },
|
{ signal: AbortSignal.timeout(10000), ...init },
|
||||||
{ bypassInternalIpAllowed: true },
|
{ bypassInternalIpAllowed: true },
|
||||||
);
|
);
|
||||||
resolvedUrl = redirectRes.url;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof SsrfBlockedError) {
|
if (err instanceof SsrfBlockedError) {
|
||||||
throw Object.assign(new Error('URL blocked by SSRF check'), { status: 403 });
|
throw Object.assign(new Error('URL blocked by SSRF check'), { status: 403 });
|
||||||
}
|
}
|
||||||
throw err;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract coordinates from Google Maps URL patterns:
|
let coords = extractCoords(resolvedUrl);
|
||||||
// /@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;
|
|
||||||
|
|
||||||
// Pattern: /@lat,lng
|
// Still nothing (e.g. a cid page whose final URL lacks coordinates): fetch the
|
||||||
const atMatch = resolvedUrl.match(/@(-?\d+\.?\d*),(-?\d+\.?\d*)/);
|
// page body once and parse the coordinates out of the embedded map data.
|
||||||
if (atMatch) { lat = parseFloat(atMatch[1]); lng = parseFloat(atMatch[2]); }
|
if (!coords) {
|
||||||
|
try {
|
||||||
// Pattern: !3dlat!4dlng (Google Maps data params)
|
const pageRes = await followRedirects(resolvedUrl, {
|
||||||
if (!lat) {
|
headers: { 'User-Agent': 'TREK-Travel-Planner/1.0' },
|
||||||
const dataMatch = resolvedUrl.match(/!3d(-?\d+\.?\d*)!4d(-?\d+\.?\d*)/);
|
});
|
||||||
if (dataMatch) { lat = parseFloat(dataMatch[1]); lng = parseFloat(dataMatch[2]); }
|
coords = extractCoords(await pageRes.text());
|
||||||
}
|
} catch (err) {
|
||||||
|
if ((err as { status?: number })?.status === 403) throw err; // SSRF block — surface it
|
||||||
// Pattern: ?q=lat,lng or &q=lat,lng
|
// Otherwise fall through to the not-found error below.
|
||||||
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/@...
|
// Extract place name from URL path: /place/Place+Name/@...
|
||||||
|
let placeName: string | null = null;
|
||||||
const placeMatch = resolvedUrl.match(/\/place\/([^/@]+)/);
|
const placeMatch = resolvedUrl.match(/\/place\/([^/@]+)/);
|
||||||
if (placeMatch) {
|
if (placeMatch) {
|
||||||
placeName = decodeURIComponent(placeMatch[1].replace(/\+/g, ' '));
|
placeName = decodeURIComponent(placeMatch[1].replace(/\+/g, ' '));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!lat || !lng || isNaN(lat) || isNaN(lng)) {
|
if (!coords || isNaN(coords.lat) || isNaN(coords.lng)) {
|
||||||
throw Object.assign(new Error('Could not extract coordinates from URL'), { status: 400 });
|
throw Object.assign(new Error('Could not extract coordinates from URL'), { status: 400 });
|
||||||
}
|
}
|
||||||
|
const { lat, lng } = coords;
|
||||||
|
|
||||||
// Reverse geocode to get address
|
// Reverse geocode to get address
|
||||||
const nominatimRes = await fetch(
|
const nominatimRes = await fetch(
|
||||||
|
|||||||
@@ -417,8 +417,10 @@ export function findOrCreateUser(
|
|||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const hash = bcrypt.hashSync(randomPass, 10);
|
const hash = bcrypt.hashSync(randomPass, 10);
|
||||||
|
|
||||||
// Username: sanitize and avoid collisions
|
// Username: sanitize and avoid collisions. Keep dots — they are valid in
|
||||||
let username = name.replace(/[^a-zA-Z0-9_-]/g, '').substring(0, 30) || 'user';
|
// usernames (see the ^[a-zA-Z0-9_.-]+$ validation in authService) and common
|
||||||
|
// in OIDC name claims like "first.last".
|
||||||
|
let username = name.replace(/[^a-zA-Z0-9_.-]/g, '').substring(0, 30) || 'user';
|
||||||
const existing = db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)').get(username);
|
const existing = db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)').get(username);
|
||||||
if (existing) username = `${username}_${Date.now() % 10000}`;
|
if (existing) username = `${username}_${Date.now() % 10000}`;
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ export interface ReservationEndpoint {
|
|||||||
local_date: string | null;
|
local_date: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type EndpointInput = Omit<ReservationEndpoint, 'id' | 'reservation_id' | 'sequence'> & { sequence?: number };
|
export type EndpointInput = Omit<ReservationEndpoint, 'id' | 'reservation_id' | 'sequence'> & { sequence?: number };
|
||||||
|
|
||||||
function loadEndpointsByTrip(tripId: string | number): Map<number, ReservationEndpoint[]> {
|
export function loadEndpointsByTrip(tripId: string | number): Map<number, ReservationEndpoint[]> {
|
||||||
const rows = db.prepare(`
|
const rows = db.prepare(`
|
||||||
SELECT e.* FROM reservation_endpoints e
|
SELECT e.* FROM reservation_endpoints e
|
||||||
JOIN reservations r ON e.reservation_id = r.id
|
JOIN reservations r ON e.reservation_id = r.id
|
||||||
@@ -110,6 +110,9 @@ export function listReservations(tripId: string | number) {
|
|||||||
for (const r of reservations) {
|
for (const r of reservations) {
|
||||||
r.day_positions = posMap.get(r.id) || null;
|
r.day_positions = posMap.get(r.id) || null;
|
||||||
r.endpoints = endpointsMap.get(r.id) || [];
|
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;
|
return reservations;
|
||||||
@@ -163,6 +166,9 @@ export function getReservationWithJoins(id: string | number) {
|
|||||||
`).get(id) as any;
|
`).get(id) as any;
|
||||||
if (!row) return undefined;
|
if (!row) return undefined;
|
||||||
row.endpoints = loadEndpoints(row.id);
|
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;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,12 +370,19 @@ export function updateReservation(id: string | number, tripId: string | number,
|
|||||||
// otherwise derive from the (possibly updated) reservation_time so the
|
// otherwise derive from the (possibly updated) reservation_time so the
|
||||||
// planner renders the booking on the correct day.
|
// planner renders the booking on the correct day.
|
||||||
let nextDayId: number | null;
|
let nextDayId: number | null;
|
||||||
if (day_id !== undefined) {
|
if (day_id != null) {
|
||||||
nextDayId = day_id || null;
|
// Explicit day from the client (e.g. moved on the planner).
|
||||||
} else if (reservation_time !== undefined && resolvedType !== 'hotel') {
|
nextDayId = day_id;
|
||||||
|
} else if (resolvedType !== 'hotel' && nextReservationTime) {
|
||||||
|
// No day set but we have a date — pin it to the matching day so the booking
|
||||||
|
// still shows in the Plan (covers bookings saved without a selected day, and
|
||||||
|
// the case where an earlier edit cleared day_id).
|
||||||
nextDayId = resolveDayIdFromTime(tripId, nextReservationTime);
|
nextDayId = resolveDayIdFromTime(tripId, nextReservationTime);
|
||||||
} else {
|
} else if (day_id === undefined) {
|
||||||
|
// Field absent and nothing to derive from — keep whatever it had.
|
||||||
nextDayId = current.day_id ?? null;
|
nextDayId = current.day_id ?? null;
|
||||||
|
} else {
|
||||||
|
nextDayId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let nextEndDayId: number | null;
|
let nextEndDayId: number | null;
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ export const DEFAULTABLE_USER_SETTING_KEYS = [
|
|||||||
'temperature_unit',
|
'temperature_unit',
|
||||||
'dark_mode',
|
'dark_mode',
|
||||||
'time_format',
|
'time_format',
|
||||||
|
// Instance-wide default currency for Costs (new users inherit it until they
|
||||||
|
// pick their own). Free-form ISO code, validated on the client.
|
||||||
|
'default_currency',
|
||||||
'blur_booking_codes',
|
'blur_booking_codes',
|
||||||
'map_tile_url',
|
'map_tile_url',
|
||||||
// Instance-wide Mapbox defaults: an admin can set a shared token + style so the
|
// Instance-wide Mapbox defaults: an admin can set a shared token + style so the
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Trip, User } from '../types';
|
|||||||
import { listDays, listAccommodations } from './dayService';
|
import { listDays, listAccommodations } from './dayService';
|
||||||
import { listBudgetItems } from './budgetService';
|
import { listBudgetItems } from './budgetService';
|
||||||
import { listItems as listPackingItems } from './packingService';
|
import { listItems as listPackingItems } from './packingService';
|
||||||
import { listReservations } from './reservationService';
|
import { listReservations, loadEndpointsByTrip } from './reservationService';
|
||||||
import { listNotes as listCollabNotes } from './collabService';
|
import { listNotes as listCollabNotes } from './collabService';
|
||||||
import { shiftOwnerEntriesForTripWindow } from './vacayService';
|
import { shiftOwnerEntriesForTripWindow } from './vacayService';
|
||||||
|
|
||||||
@@ -516,27 +516,54 @@ 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
|
// Reservations as events
|
||||||
for (const r of reservations) {
|
for (const r of reservations) {
|
||||||
if (!r.reservation_time) continue;
|
const timeLines = buildReservationTimeLines(r);
|
||||||
// Skip time-only values (no calendar date — occurs on relative "Day N" trips)
|
if (!timeLines) continue;
|
||||||
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) : {};
|
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 += `BEGIN:VEVENT\r\nUID:${uid(r.id, 'res')}\r\nDTSTAMP:${now}\r\n`;
|
||||||
if (hasTime) {
|
ics += timeLines;
|
||||||
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`;
|
ics += `SUMMARY:${esc(r.title)}\r\n`;
|
||||||
|
|
||||||
let desc = r.type ? `Type: ${r.type}` : '';
|
let desc = r.type ? `Type: ${r.type}` : '';
|
||||||
@@ -547,9 +574,16 @@ export function exportICS(tripId: string | number): { ics: string; filename: str
|
|||||||
// Multi-leg flight: show the whole route (FRA → BER → HND) on one event.
|
// 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);
|
const stops = [meta.legs[0]?.from, ...meta.legs.map((l: { to?: string }) => l.to)].filter(Boolean);
|
||||||
if (stops.length) desc += `\nRoute: ${stops.join(' → ')}`;
|
if (stops.length) desc += `\nRoute: ${stops.join(' → ')}`;
|
||||||
} else {
|
} else if (meta.departure_airport || meta.arrival_airport) {
|
||||||
if (meta.departure_airport) desc += `\nFrom: ${meta.departure_airport}`;
|
if (meta.departure_airport) desc += `\nFrom: ${meta.departure_airport}`;
|
||||||
if (meta.arrival_airport) desc += `\nTo: ${meta.arrival_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 (meta.train_number) desc += `\nTrain: ${meta.train_number}`;
|
||||||
if (r.notes) desc += `\n${r.notes}`;
|
if (r.notes) desc += `\n${r.notes}`;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const { svc } = vi.hoisted(() => ({
|
|||||||
verifyTripAccess: vi.fn(), listBudgetItems: vi.fn(), createBudgetItem: vi.fn(), updateBudgetItem: vi.fn(),
|
verifyTripAccess: vi.fn(), listBudgetItems: vi.fn(), createBudgetItem: vi.fn(), updateBudgetItem: vi.fn(),
|
||||||
deleteBudgetItem: vi.fn(), updateMembers: vi.fn(), toggleMemberPaid: vi.fn(), getPerPersonSummary: vi.fn(),
|
deleteBudgetItem: vi.fn(), updateMembers: vi.fn(), toggleMemberPaid: vi.fn(), getPerPersonSummary: vi.fn(),
|
||||||
calculateSettlement: vi.fn(), reorderBudgetItems: vi.fn(), reorderBudgetCategories: vi.fn(),
|
calculateSettlement: vi.fn(), reorderBudgetItems: vi.fn(), reorderBudgetCategories: vi.fn(),
|
||||||
|
setItemPayers: vi.fn(), listSettlements: vi.fn(), createSettlement: vi.fn(), updateSettlement: vi.fn(), deleteSettlement: vi.fn(),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/services/budgetService', () => svc);
|
vi.mock('../../src/services/budgetService', () => svc);
|
||||||
@@ -104,4 +105,18 @@ describe('Budget e2e (real auth guard + temp SQLite)', () => {
|
|||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
expect(res.body).toEqual({ error: 'user_ids must be an array' });
|
expect(res.body).toEqual({ error: 'user_ids must be an array' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('200 on settlement update with permission', async () => {
|
||||||
|
svc.updateSettlement.mockReturnValue({ id: 7, from_user_id: 2, to_user_id: 1, amount: 15 });
|
||||||
|
const res = await request(server).put('/api/trips/5/budget/settlements/7').set('Cookie', sessionCookie(1)).send({ from_user_id: 2, to_user_id: 1, amount: 15 });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({ settlement: { id: 7, from_user_id: 2, to_user_id: 1, amount: 15 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404 on settlement update when it does not exist', async () => {
|
||||||
|
svc.updateSettlement.mockReturnValue(null);
|
||||||
|
const res = await request(server).put('/api/trips/5/budget/settlements/7').set('Cookie', sessionCookie(1)).send({ from_user_id: 2, to_user_id: 1, amount: 15 });
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body).toEqual({ error: 'Settlement not found' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -185,6 +185,44 @@ describe('Update reservation', () => {
|
|||||||
expect(res.body.reservation.confirmation_number).toBe('ABC123');
|
expect(res.body.reservation.confirmation_number).toBe('ABC123');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('RESV-004b — PUT with day_id null derives day_id from reservation_time so it stays in the Plan (#1237)', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
createDay(testDb, trip.id, { date: '2025-09-01' });
|
||||||
|
const day2 = createDay(testDb, trip.id, { date: '2025-09-02' });
|
||||||
|
const resv = createReservation(testDb, trip.id, { title: 'Event', type: 'event' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}/reservations/${resv.id}`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ title: 'Event', type: 'event', day_id: null, reservation_time: '2025-09-02' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.reservation.day_id).toBe(day2.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('RESV-004c — re-dating a booking moves it to the matching day (start + end) (#1237)', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const day1 = createDay(testDb, trip.id, { date: '2025-10-01' });
|
||||||
|
const day3 = createDay(testDb, trip.id, { date: '2025-10-03' });
|
||||||
|
|
||||||
|
// Booking sits on day 1 (start + end).
|
||||||
|
const created = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/reservations`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ title: 'Event', type: 'event', day_id: day1.id, reservation_time: '2025-10-01T09:00', reservation_end_time: '2025-10-01T10:00' });
|
||||||
|
const rid = created.body.reservation.id;
|
||||||
|
|
||||||
|
// Re-date to day 3 WITHOUT sending day_id (the modal omits it) — both ends follow.
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}/reservations/${rid}`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ title: 'Event', type: 'event', reservation_time: '2025-10-03T00:00', reservation_end_time: '2025-10-03T14:00' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.reservation.day_id).toBe(day3.id);
|
||||||
|
expect(res.body.reservation.end_day_id).toBe(day3.id);
|
||||||
|
});
|
||||||
|
|
||||||
it('RESV-004 — PUT on non-existent reservation returns 404', async () => {
|
it('RESV-004 — PUT on non-existent reservation returns 404', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const trip = createTrip(testDb, user.id);
|
const trip = createTrip(testDb, user.id);
|
||||||
@@ -382,7 +420,7 @@ describe('Reservation budget entry integration', () => {
|
|||||||
expect(items[0].total_price).toBe(150);
|
expect(items[0].total_price).toBe(150);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('RESV-014 — PUT without create_budget_entry removes existing linked budget item', async () => {
|
it('RESV-014 — PUT without create_budget_entry keeps the existing linked budget item', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const trip = createTrip(testDb, user.id);
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
@@ -398,24 +436,98 @@ describe('Reservation budget entry integration', () => {
|
|||||||
expect(createRes.status).toBe(201);
|
expect(createRes.status).toBe(201);
|
||||||
const resvId = createRes.body.reservation.id;
|
const resvId = createRes.body.reservation.id;
|
||||||
|
|
||||||
// Verify budget item exists
|
|
||||||
const before = testDb
|
const before = testDb
|
||||||
.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
|
.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
|
||||||
.get(trip.id, resvId);
|
.get(trip.id, resvId);
|
||||||
expect(before).toBeDefined();
|
expect(before).toBeDefined();
|
||||||
|
|
||||||
// Update without create_budget_entry — should delete the linked budget item
|
// Update WITHOUT create_budget_entry — the booking edit must NOT touch its
|
||||||
|
// linked expense (expenses are managed from the Costs section now).
|
||||||
const updateRes = await request(app)
|
const updateRes = await request(app)
|
||||||
.put(`/api/trips/${trip.id}/reservations/${resvId}`)
|
.put(`/api/trips/${trip.id}/reservations/${resvId}`)
|
||||||
.set('Cookie', authCookie(user.id))
|
.set('Cookie', authCookie(user.id))
|
||||||
.send({ title: 'Taxi Updated' });
|
.send({ title: 'Taxi Updated' });
|
||||||
expect(updateRes.status).toBe(200);
|
expect(updateRes.status).toBe(200);
|
||||||
|
|
||||||
|
const after = testDb
|
||||||
|
.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
|
||||||
|
.get(trip.id, resvId);
|
||||||
|
expect(after).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('RESV-014b — PUT with create_budget_entry total_price 0 removes the linked budget item', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const createRes = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/reservations`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({
|
||||||
|
title: 'Taxi',
|
||||||
|
type: 'transport',
|
||||||
|
create_budget_entry: { total_price: 50, category: 'Transport' },
|
||||||
|
});
|
||||||
|
expect(createRes.status).toBe(201);
|
||||||
|
const resvId = createRes.body.reservation.id;
|
||||||
|
|
||||||
|
// Explicit clear (total_price 0) still removes the linked item.
|
||||||
|
const updateRes = await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}/reservations/${resvId}`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ title: 'Taxi', create_budget_entry: { total_price: 0 } });
|
||||||
|
expect(updateRes.status).toBe(200);
|
||||||
|
|
||||||
const after = testDb
|
const after = testDb
|
||||||
.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
|
.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
|
||||||
.get(trip.id, resvId);
|
.get(trip.id, resvId);
|
||||||
expect(after).toBeUndefined();
|
expect(after).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('RESV-014c — changing the booking type updates the linked expense category', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const createRes = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/reservations`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ title: 'Booking', type: 'other', create_budget_entry: { total_price: 50, category: 'other' } });
|
||||||
|
const resvId = createRes.body.reservation.id;
|
||||||
|
|
||||||
|
// Change the type other -> hotel (no create_budget_entry).
|
||||||
|
await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}/reservations/${resvId}`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ title: 'Booking', type: 'hotel' });
|
||||||
|
|
||||||
|
const item = testDb
|
||||||
|
.prepare('SELECT category FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
|
||||||
|
.get(trip.id, resvId) as { category: string };
|
||||||
|
expect(item.category).toBe('accommodation');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('RESV-014d — a manually-picked expense category survives a booking type change', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
const createRes = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/reservations`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ title: 'Booking', type: 'other', create_budget_entry: { total_price: 50, category: 'other' } });
|
||||||
|
const resvId = createRes.body.reservation.id;
|
||||||
|
|
||||||
|
// Simulate a manual category pick in the Costs editor.
|
||||||
|
testDb.prepare('UPDATE budget_items SET category = ? WHERE trip_id = ? AND reservation_id = ?').run('fees', trip.id, resvId);
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.put(`/api/trips/${trip.id}/reservations/${resvId}`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ title: 'Booking', type: 'hotel' });
|
||||||
|
|
||||||
|
const item = testDb
|
||||||
|
.prepare('SELECT category FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
|
||||||
|
.get(trip.id, resvId) as { category: string };
|
||||||
|
expect(item.category).toBe('fees');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Reservation accommodation delete', () => {
|
describe('Reservation accommodation delete', () => {
|
||||||
|
|||||||
@@ -128,10 +128,12 @@ describe('Tool: mark_region_visited', () => {
|
|||||||
arguments: { regionCode: 'US-CA', regionName: 'California', countryCode: 'US' },
|
arguments: { regionCode: 'US-CA', regionName: 'California', countryCode: 'US' },
|
||||||
});
|
});
|
||||||
const data = parseToolResult(result) as any;
|
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).toBeDefined();
|
||||||
expect(data.region.region_code).toBe('US-CA');
|
expect(data.region.code).toBe('US-CA');
|
||||||
expect(data.region.region_name).toBe('California');
|
expect(data.region.name).toBe('California');
|
||||||
expect(data.region.country_code).toBe('US');
|
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');
|
const row = testDb.prepare('SELECT * FROM visited_regions WHERE user_id = ? AND region_code = ?').get(user.id, 'US-CA');
|
||||||
expect(row).toBeTruthy();
|
expect(row).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
|||||||
import { createTables } from '../../../src/db/schema';
|
import { createTables } from '../../../src/db/schema';
|
||||||
import { runMigrations } from '../../../src/db/migrations';
|
import { runMigrations } from '../../../src/db/migrations';
|
||||||
import { resetTestDb } from '../../helpers/test-db';
|
import { resetTestDb } from '../../helpers/test-db';
|
||||||
import { createUser, createTrip, createBudgetItem } from '../../helpers/factories';
|
import { createUser, createTrip, createBudgetItem, addTripMember } from '../../helpers/factories';
|
||||||
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
@@ -70,7 +70,7 @@ async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promis
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('Tool: set_budget_item_members', () => {
|
describe('Tool: set_budget_item_members', () => {
|
||||||
it('sets members and broadcasts budget:members-updated', async () => {
|
it('sets members and returns a hydrated item with members/payers', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const trip = createTrip(testDb, user.id);
|
const trip = createTrip(testDb, user.id);
|
||||||
const item = createBudgetItem(testDb, trip.id, { name: 'Flights', total_price: 500 });
|
const item = createBudgetItem(testDb, trip.id, { name: 'Flights', total_price: 500 });
|
||||||
@@ -80,11 +80,25 @@ describe('Tool: set_budget_item_members', () => {
|
|||||||
arguments: { tripId: trip.id, itemId: item.id, userIds: [user.id] },
|
arguments: { tripId: trip.id, itemId: item.id, userIds: [user.id] },
|
||||||
});
|
});
|
||||||
const data = parseToolResult(result) as any;
|
const data = parseToolResult(result) as any;
|
||||||
expect(data.item).toBeDefined();
|
// 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(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:members-updated', expect.any(Object));
|
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 () => {
|
it('empty array clears members', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const trip = createTrip(testDb, user.id);
|
const trip = createTrip(testDb, user.id);
|
||||||
@@ -131,6 +145,58 @@ 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
|
// toggle_budget_member_paid
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -168,6 +234,115 @@ 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
|
// Per-person resource
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
|||||||
import { createTables } from '../../../src/db/schema';
|
import { createTables } from '../../../src/db/schema';
|
||||||
import { runMigrations } from '../../../src/db/migrations';
|
import { runMigrations } from '../../../src/db/migrations';
|
||||||
import { resetTestDb } from '../../helpers/test-db';
|
import { resetTestDb } from '../../helpers/test-db';
|
||||||
import { createUser, createTrip, createBudgetItem } from '../../helpers/factories';
|
import { createUser, createTrip, createBudgetItem, addTripMember } from '../../helpers/factories';
|
||||||
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
@@ -101,6 +101,89 @@ 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 () => {
|
it('returns access denied for non-member', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const { user: other } = createUser(testDb);
|
const { user: other } = createUser(testDb);
|
||||||
|
|||||||
@@ -168,12 +168,14 @@ describe('Tool: create_accommodation', () => {
|
|||||||
start_day_id: day1.id,
|
start_day_id: day1.id,
|
||||||
end_day_id: day2.id,
|
end_day_id: day2.id,
|
||||||
check_in: '15:00',
|
check_in: '15:00',
|
||||||
|
check_in_end: '20:00',
|
||||||
check_out: '11:00',
|
check_out: '11:00',
|
||||||
confirmation: 'CONF123',
|
confirmation: 'CONF123',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const data = parseToolResult(result) as any;
|
const data = parseToolResult(result) as any;
|
||||||
expect(data.accommodation).toBeDefined();
|
expect(data.accommodation).toBeDefined();
|
||||||
|
expect(data.accommodation.check_in_end).toBe('20:00');
|
||||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:created', expect.any(Object));
|
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:created', expect.any(Object));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* 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 { createTables } from '../../../src/db/schema';
|
||||||
import { runMigrations } from '../../../src/db/migrations';
|
import { runMigrations } from '../../../src/db/migrations';
|
||||||
import { resetTestDb } from '../../helpers/test-db';
|
import { resetTestDb } from '../../helpers/test-db';
|
||||||
import { createUser, createTrip, createPackingItem } from '../../helpers/factories';
|
import { createUser, createAdmin, createTrip, createPackingItem } from '../../helpers/factories';
|
||||||
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
@@ -148,6 +148,8 @@ describe('Tool: create_packing_bag', () => {
|
|||||||
const data = parseToolResult(result) as any;
|
const data = parseToolResult(result) as any;
|
||||||
expect(data.bag).toBeDefined();
|
expect(data.bag).toBeDefined();
|
||||||
expect(data.bag.name).toBe('Checked bag');
|
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));
|
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:bag-created', expect.any(Object));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -267,8 +269,9 @@ describe('Tool: set_bag_members', () => {
|
|||||||
arguments: { tripId: trip.id, bagId, userIds: [user.id] },
|
arguments: { tripId: trip.id, bagId, userIds: [user.id] },
|
||||||
});
|
});
|
||||||
const data = parseToolResult(result) as any;
|
const data = parseToolResult(result) as any;
|
||||||
expect(data.success).toBe(true);
|
// Returns the hydrated members list (REST parity), not { success }.
|
||||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:bag-members-updated', expect.any(Object));
|
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) }));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -284,7 +287,7 @@ describe('Tool: set_bag_members', () => {
|
|||||||
arguments: { tripId: trip.id, bagId, userIds: [] },
|
arguments: { tripId: trip.id, bagId, userIds: [] },
|
||||||
});
|
});
|
||||||
const data = parseToolResult(result) as any;
|
const data = parseToolResult(result) as any;
|
||||||
expect(data.success).toBe(true);
|
expect(data.members).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -322,8 +325,9 @@ describe('Tool: set_packing_category_assignees', () => {
|
|||||||
arguments: { tripId: trip.id, categoryName: 'Clothing', userIds: [user.id] },
|
arguments: { tripId: trip.id, categoryName: 'Clothing', userIds: [user.id] },
|
||||||
});
|
});
|
||||||
const data = parseToolResult(result) as any;
|
const data = parseToolResult(result) as any;
|
||||||
expect(data.success).toBe(true);
|
// Returns the hydrated assignees list (REST parity), not { success }.
|
||||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:assignees', expect.any(Object));
|
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) }));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -337,7 +341,7 @@ describe('Tool: set_packing_category_assignees', () => {
|
|||||||
arguments: { tripId: trip.id, categoryName: 'Clothing', userIds: [] },
|
arguments: { tripId: trip.id, categoryName: 'Clothing', userIds: [] },
|
||||||
});
|
});
|
||||||
const data = parseToolResult(result) as any;
|
const data = parseToolResult(result) as any;
|
||||||
expect(data.success).toBe(true);
|
expect(data.assignees).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -378,8 +382,8 @@ describe('Tool: apply_packing_template', () => {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('Tool: save_packing_template', () => {
|
describe('Tool: save_packing_template', () => {
|
||||||
it('saves the current packing list as a template', async () => {
|
it('saves the current packing list as a template for an admin', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createAdmin(testDb);
|
||||||
const trip = createTrip(testDb, user.id);
|
const trip = createTrip(testDb, user.id);
|
||||||
createPackingItem(testDb, trip.id, { name: 'Toothbrush', category: 'Toiletries' });
|
createPackingItem(testDb, trip.id, { name: 'Toothbrush', category: 'Toiletries' });
|
||||||
await withHarness(user.id, async (h) => {
|
await withHarness(user.id, async (h) => {
|
||||||
@@ -388,7 +392,36 @@ describe('Tool: save_packing_template', () => {
|
|||||||
arguments: { tripId: trip.id, templateName: 'Weekend Trip' },
|
arguments: { tripId: trip.id, templateName: 'Weekend Trip' },
|
||||||
});
|
});
|
||||||
const data = parseToolResult(result) as any;
|
const data = parseToolResult(result) as any;
|
||||||
expect(data.success).toBe(true);
|
// 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 () => {
|
||||||
|
const { user } = createUser(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' },
|
||||||
|
});
|
||||||
|
expect(result.isError).toBe(true);
|
||||||
|
expect((result.content as any)[0].text).toBe('Admin access required');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -406,12 +439,96 @@ 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
|
// bulk_import_packing
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('Tool: bulk_import_packing', () => {
|
describe('Tool: bulk_import_packing', () => {
|
||||||
it('imports multiple packing items and count matches', async () => {
|
it('imports multiple packing items, returns them, and broadcasts per item', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const trip = createTrip(testDb, user.id);
|
const trip = createTrip(testDb, user.id);
|
||||||
const items = [
|
const items = [
|
||||||
@@ -425,9 +542,33 @@ describe('Tool: bulk_import_packing', () => {
|
|||||||
arguments: { tripId: trip.id, items },
|
arguments: { tripId: trip.id, items },
|
||||||
});
|
});
|
||||||
const data = parseToolResult(result) as any;
|
const data = parseToolResult(result) as any;
|
||||||
expect(data.success).toBe(true);
|
// New contract: returns the created items (REST parity), broadcasts packing:created per item.
|
||||||
expect(data.count).toBe(items.length);
|
expect(data.count).toBe(items.length);
|
||||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:updated', expect.any(Object));
|
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
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -350,6 +350,10 @@ describe('Tool: link_hotel_accommodation', () => {
|
|||||||
const data = parseToolResult(result) as any;
|
const data = parseToolResult(result) as any;
|
||||||
expect(data.reservation.accommodation_id).not.toBeNull();
|
expect(data.reservation.accommodation_id).not.toBeNull();
|
||||||
expect(data.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));
|
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:created', expect.any(Object));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
/**
|
||||||
|
* 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,7 +49,9 @@ vi.mock('../../../src/services/vacayService', async (importOriginal) => {
|
|||||||
const original = await importOriginal() as Record<string, unknown>;
|
const original = await importOriginal() as Record<string, unknown>;
|
||||||
return {
|
return {
|
||||||
...original,
|
...original,
|
||||||
updatePlan: vi.fn().mockResolvedValue(undefined),
|
updatePlan: vi.fn().mockResolvedValue({
|
||||||
|
plan: { id: 1, block_weekends: true, holidays_enabled: false, company_holidays_enabled: false, carry_over_enabled: false, holiday_calendars: [] },
|
||||||
|
}),
|
||||||
getCountries: vi.fn().mockResolvedValue({ data: [{ code: 'US', name: 'United States' }] }),
|
getCountries: vi.fn().mockResolvedValue({ data: [{ code: 'US', name: 'United States' }] }),
|
||||||
getHolidays: vi.fn().mockResolvedValue({ data: [{ date: '2025-01-01', name: 'New Year' }] }),
|
getHolidays: vi.fn().mockResolvedValue({ data: [{ date: '2025-01-01', name: 'New Year' }] }),
|
||||||
};
|
};
|
||||||
@@ -106,7 +108,7 @@ describe('Tool: get_vacay_plan', () => {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('Tool: update_vacay_plan', () => {
|
describe('Tool: update_vacay_plan', () => {
|
||||||
it('calls updatePlan and returns success', async () => {
|
it('calls updatePlan and returns the hydrated plan', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
await withHarness(user.id, async (h) => {
|
await withHarness(user.id, async (h) => {
|
||||||
const result = await h.client.callTool({
|
const result = await h.client.callTool({
|
||||||
@@ -114,7 +116,11 @@ describe('Tool: update_vacay_plan', () => {
|
|||||||
arguments: { block_weekends: true, holidays_enabled: false },
|
arguments: { block_weekends: true, holidays_enabled: false },
|
||||||
});
|
});
|
||||||
const data = parseToolResult(result) as any;
|
const data = parseToolResult(result) as any;
|
||||||
expect(data.success).toBe(true);
|
// 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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -136,9 +142,10 @@ describe('Tool: set_vacay_color', () => {
|
|||||||
it('updates color and returns success', async () => {
|
it('updates color and returns success', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
await withHarness(user.id, async (h) => {
|
await withHarness(user.id, async (h) => {
|
||||||
const result = await h.client.callTool({ name: 'set_vacay_color', arguments: { color: '#6366f1' } });
|
const result = await h.client.callTool({ name: 'set_vacay_color', arguments: { color: '#ff0000' } });
|
||||||
const data = parseToolResult(result) as any;
|
const data = parseToolResult(result) as any;
|
||||||
expect(data.success).toBe(true);
|
expect(data.success).toBe(true);
|
||||||
|
expect(data.color).toBe('#ff0000'); // echoes the persisted color
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,37 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou
|
|||||||
expect(new BudgetController(svc).deleteSettlement(user, '5', '7', 'sock')).toEqual({ success: true });
|
expect(new BudgetController(svc).deleteSettlement(user, '5', '7', 'sock')).toEqual({ success: true });
|
||||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:settlement-deleted', { settlementId: 7 }, 'sock');
|
expect(broadcast).toHaveBeenCalledWith('5', 'budget:settlement-deleted', { settlementId: 7 }, 'sock');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('PUT /settlements/:id 403 without budget_edit', () => {
|
||||||
|
const svc = makeService({ canEdit: vi.fn().mockReturnValue(false) });
|
||||||
|
expect(thrown(() => new BudgetController(svc).updateSettlement(user, '5', '7', { from_user_id: 1, to_user_id: 2, amount: 10 }))).toEqual({
|
||||||
|
status: 403, body: { error: 'No permission' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT /settlements/:id 400 when a field is missing', () => {
|
||||||
|
const svc = makeService();
|
||||||
|
expect(thrown(() => new BudgetController(svc).updateSettlement(user, '5', '7', { from_user_id: 1, to_user_id: 2 }))).toEqual({
|
||||||
|
status: 400, body: { error: 'from_user_id, to_user_id and amount are required' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT /settlements/:id 404 when missing', () => {
|
||||||
|
const svc = makeService({ updateSettlement: vi.fn().mockReturnValue(null) } as Partial<BudgetService>);
|
||||||
|
expect(thrown(() => new BudgetController(svc).updateSettlement(user, '5', '7', { from_user_id: 1, to_user_id: 2, amount: 10 }))).toEqual({
|
||||||
|
status: 404, body: { error: 'Settlement not found' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT /settlements/:id updates and broadcasts', () => {
|
||||||
|
const updateSettlement = vi.fn().mockReturnValue({ id: 7, from_user_id: 2, to_user_id: 1, amount: 15 });
|
||||||
|
const broadcast = vi.fn();
|
||||||
|
const svc = makeService({ updateSettlement, broadcast } as Partial<BudgetService>);
|
||||||
|
const res = new BudgetController(svc).updateSettlement(user, '5', '7', { from_user_id: 2, to_user_id: 1, amount: 15 }, 'sock');
|
||||||
|
expect(res).toEqual({ settlement: { id: 7, from_user_id: 2, to_user_id: 1, amount: 15 } });
|
||||||
|
expect(updateSettlement).toHaveBeenCalledWith('7', '5', { from_user_id: 2, to_user_id: 1, amount: 15 });
|
||||||
|
expect(broadcast).toHaveBeenCalledWith('5', 'budget:settlement-updated', { settlement: { id: 7, from_user_id: 2, to_user_id: 1, amount: 15 } }, 'sock');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /', () => {
|
describe('POST /', () => {
|
||||||
|
|||||||
@@ -75,13 +75,28 @@ describe('ReservationsService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('syncBudgetOnUpdate', () => {
|
describe('syncBudgetOnUpdate', () => {
|
||||||
it('deletes the linked item when the price is cleared', () => {
|
it('deletes the linked item when the price is explicitly cleared (total_price 0)', () => {
|
||||||
dbMock._stmt.get.mockReturnValueOnce({ id: 7 });
|
dbMock._stmt.get.mockReturnValueOnce({ id: 7 });
|
||||||
svc().syncBudgetOnUpdate('5', '9', 'Hotel', 'lodging', 'Hotel', 'lodging', undefined, 'sock');
|
svc().syncBudgetOnUpdate('5', '9', 'Hotel', 'lodging', 'Hotel', 'lodging', { total_price: 0 }, 'sock');
|
||||||
expect(budget.deleteBudgetItem).toHaveBeenCalledWith(7, '5');
|
expect(budget.deleteBudgetItem).toHaveBeenCalledWith(7, '5');
|
||||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:deleted', { itemId: 7 }, 'sock');
|
expect(broadcast).toHaveBeenCalledWith('5', 'budget:deleted', { itemId: 7 }, 'sock');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('leaves the linked item alone when no budget entry is on the payload (no wipe)', () => {
|
||||||
|
svc().syncBudgetOnUpdate('5', '9', 'Hotel', 'lodging', 'Hotel', 'lodging', undefined, 'sock');
|
||||||
|
expect(budget.deleteBudgetItem).not.toHaveBeenCalled();
|
||||||
|
expect(budget.updateBudgetItem).not.toHaveBeenCalled();
|
||||||
|
expect(budget.createBudgetItem).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('syncs the linked expense category when the booking type changes', () => {
|
||||||
|
dbMock._stmt.get.mockReturnValueOnce({ id: 7, category: 'other' });
|
||||||
|
budget.updateBudgetItem.mockReturnValue({ id: 7, category: 'flights' });
|
||||||
|
svc().syncBudgetOnUpdate('5', '9', 'X', 'flight', 'X', 'other', undefined, 'sock');
|
||||||
|
expect(budget.updateBudgetItem).toHaveBeenCalledWith(7, '5', { category: 'flights' });
|
||||||
|
expect(broadcast).toHaveBeenCalledWith('5', 'budget:updated', { item: { id: 7, category: 'flights' } }, 'sock');
|
||||||
|
});
|
||||||
|
|
||||||
it('updates an existing linked item when a price is provided', () => {
|
it('updates an existing linked item when a price is provided', () => {
|
||||||
dbMock._stmt.get.mockReturnValueOnce({ id: 7 }); // existing lookup
|
dbMock._stmt.get.mockReturnValueOnce({ id: 7 }); // existing lookup
|
||||||
budget.updateBudgetItem.mockReturnValue({ id: 7 });
|
budget.updateBudgetItem.mockReturnValue({ id: 7 });
|
||||||
|
|||||||
@@ -23,8 +23,11 @@ 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' }),
|
to: airport({ id: 2, icao: 'EGLL', iata: 'LHR', name: 'London Heathrow', lat: 51.4706, lon: -0.4619, tz: 'Europe/London' }),
|
||||||
date: '2021-09-01',
|
date: '2021-09-01',
|
||||||
datePrecision: 'day',
|
datePrecision: 'day',
|
||||||
departure: '2021-09-01T23:00:00.000+00:00', // 19:00 local at JFK (EDT, UTC-4)
|
// Actual times (delayed) — TREK must IGNORE these and read the scheduled times.
|
||||||
arrival: '2021-09-02T07:00:00.000+00:00', // 08:00 local at LHR (BST, UTC+1)
|
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)
|
||||||
airline: { id: 1, icao: 'BAW', iata: 'BA', name: 'British Airways' },
|
airline: { id: 1, icao: 'BAW', iata: 'BA', name: 'British Airways' },
|
||||||
flightNumber: 'BA178',
|
flightNumber: 'BA178',
|
||||||
aircraft: { id: 1, icao: 'B772', name: 'Boeing 777' },
|
aircraft: { id: 1, icao: 'B772', name: 'Boeing 777' },
|
||||||
@@ -48,6 +51,9 @@ describe('airtrailMapper.normalizeFlight', () => {
|
|||||||
flightNumber: 'BA178',
|
flightNumber: 'BA178',
|
||||||
seatClass: 'economy',
|
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', () => {
|
it('falls back to ICAO when IATA is missing and tolerates null airports', () => {
|
||||||
@@ -59,14 +65,24 @@ describe('airtrailMapper.normalizeFlight', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('airtrailMapper.mapFlightToReservation', () => {
|
describe('airtrailMapper.mapFlightToReservation', () => {
|
||||||
it('composes airport-local times from the instant + airport tz', () => {
|
it('composes airport-local times from the SCHEDULED instant + airport tz', () => {
|
||||||
const m = mapFlightToReservation(flight());
|
const m = mapFlightToReservation(flight());
|
||||||
// 23:00 UTC at JFK in September is 19:00 EDT; date stays the AirTrail local date.
|
// 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.)
|
||||||
expect(m.reservation_time).toBe('2021-09-01T19:00');
|
expect(m.reservation_time).toBe('2021-09-01T19:00');
|
||||||
// 07:00 UTC at LHR in September is 08:00 BST.
|
// Scheduled 07:00 UTC at LHR in September is 08:00 BST.
|
||||||
expect(m.reservation_end_time).toBe('2021-09-02T08:00');
|
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', () => {
|
it('builds two endpoints with codes, coords and timezones', () => {
|
||||||
const m = mapFlightToReservation(flight());
|
const m = mapFlightToReservation(flight());
|
||||||
expect(m.endpoints).toHaveLength(2);
|
expect(m.endpoints).toHaveLength(2);
|
||||||
@@ -88,6 +104,26 @@ describe('airtrailMapper.mapFlightToReservation', () => {
|
|||||||
expect(m.notes).toBe('window seat');
|
expect(m.notes).toBe('window seat');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses only the seat number for the seat, not the cabin class (#1246)', () => {
|
||||||
|
// AirTrail often has a class but no seat number until check-in; the class
|
||||||
|
// must not leak into the seat field.
|
||||||
|
const m = mapFlightToReservation(
|
||||||
|
flight({ seats: [{ userId: 'u1', guestName: null, seat: null, seatNumber: null, seatClass: 'economy' }] }),
|
||||||
|
);
|
||||||
|
expect(m.metadata).not.toHaveProperty('seat');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the seat number when present even with no class', () => {
|
||||||
|
const m = mapFlightToReservation(
|
||||||
|
flight({ seats: [{ userId: 'u1', guestName: null, seat: null, seatNumber: '3F', seatClass: null }] }),
|
||||||
|
);
|
||||||
|
expect(m.metadata).toMatchObject({ seat: '3F' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the seat for a flight with no seats', () => {
|
||||||
|
expect(mapFlightToReservation(flight({ seats: [] })).metadata).not.toHaveProperty('seat');
|
||||||
|
});
|
||||||
|
|
||||||
it('flags needs_review for a non-day date precision', () => {
|
it('flags needs_review for a non-day date precision', () => {
|
||||||
expect(mapFlightToReservation(flight({ datePrecision: 'month' })).needs_review).toBe(1);
|
expect(mapFlightToReservation(flight({ datePrecision: 'month' })).needs_review).toBe(1);
|
||||||
});
|
});
|
||||||
@@ -99,8 +135,8 @@ describe('airtrailMapper.mapFlightToReservation', () => {
|
|||||||
expect(m.endpoints.find(e => e.role === 'to')).toBeDefined();
|
expect(m.endpoints.find(e => e.role === 'to')).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('leaves the end time null for a partial flight with no arrival', () => {
|
it('leaves the end time null for a partial flight with no scheduled arrival', () => {
|
||||||
const m = mapFlightToReservation(flight({ arrival: null }));
|
const m = mapFlightToReservation(flight({ arrivalScheduled: null }));
|
||||||
expect(m.reservation_end_time).toBeNull();
|
expect(m.reservation_end_time).toBeNull();
|
||||||
expect(m.reservation_time).toBe('2021-09-01T19:00');
|
expect(m.reservation_time).toBe('2021-09-01T19:00');
|
||||||
});
|
});
|
||||||
@@ -116,6 +152,17 @@ describe('airtrailMapper.canonicalHash', () => {
|
|||||||
expect(canonicalHash(flight())).not.toBe(canonicalHash(flight({ note: 'aisle seat' })));
|
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', () => {
|
it('is independent of seat ordering', () => {
|
||||||
const a = flight({
|
const a = flight({
|
||||||
seats: [
|
seats: [
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { buildSavePayload } from '../../../src/services/airtrail/airtrailSync';
|
||||||
|
import type { AirtrailAirport, AirtrailFlightRaw } from '../../../src/services/airtrail/airtrailClient';
|
||||||
|
|
||||||
|
function airport(over: Partial<AirtrailAirport> = {}): AirtrailAirport {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
icao: 'KJFK',
|
||||||
|
iata: 'JFK',
|
||||||
|
name: 'John F. Kennedy Intl.',
|
||||||
|
lat: 40.6413,
|
||||||
|
lon: -73.7781,
|
||||||
|
tz: 'America/New_York',
|
||||||
|
country: 'US',
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An AirTrail flight as GET returns it, including the fields TREK doesn't model.
|
||||||
|
* Typed as the raw object (known shape + arbitrary passthrough keys) because the
|
||||||
|
* push spreads it wholesale rather than mapping each field — see buildSavePayload.
|
||||||
|
*/
|
||||||
|
function existingFlight(
|
||||||
|
over: Partial<AirtrailFlightRaw> & Record<string, unknown> = {},
|
||||||
|
): AirtrailFlightRaw & Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
id: 42,
|
||||||
|
from: airport(),
|
||||||
|
to: airport({ id: 2, icao: 'EGLL', iata: 'LHR', name: 'London Heathrow', tz: 'Europe/London' }),
|
||||||
|
date: '2021-09-01',
|
||||||
|
datePrecision: 'day',
|
||||||
|
departure: '2021-09-01T23:00:00.000+00:00',
|
||||||
|
arrival: '2021-09-02T07:00:00.000+00:00',
|
||||||
|
airline: { id: 1, icao: 'BAW', iata: 'BA', name: 'British Airways' },
|
||||||
|
flightNumber: 'BA178',
|
||||||
|
aircraft: { id: 1, icao: 'B772', name: 'Boeing 777' },
|
||||||
|
aircraftReg: 'G-VIIL',
|
||||||
|
flightReason: 'leisure',
|
||||||
|
note: 'window seat',
|
||||||
|
seats: [{ userId: 'u1', guestName: null, seat: 'window', seatNumber: '12A', seatClass: 'economy' }],
|
||||||
|
// AirTrail-owned detail TREK never surfaces — must survive a writeback (#1240).
|
||||||
|
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',
|
||||||
|
landingActualTime: '07:55',
|
||||||
|
departureTerminal: '7',
|
||||||
|
departureGate: 'B22',
|
||||||
|
arrivalTerminal: '5',
|
||||||
|
arrivalGate: 'A10',
|
||||||
|
customFields: { confirmation: 'ABC123' },
|
||||||
|
track: [{ lat: 40.6, lon: -73.7 }],
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A linked TREK reservation (the shape getReservationWithJoins returns). */
|
||||||
|
function reservation(over: Record<string, unknown> = {}): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
external_id: '42',
|
||||||
|
reservation_time: '2021-09-01T19:00',
|
||||||
|
reservation_end_time: '2021-09-02T08:00',
|
||||||
|
notes: 'window seat',
|
||||||
|
metadata: JSON.stringify({ airline: 'BAW', flight_number: 'BA178', aircraft: 'B772', aircraft_reg: 'G-VIIL', flight_reason: 'leisure', seat: '12A' }),
|
||||||
|
endpoints: [
|
||||||
|
{ role: 'from', code: 'JFK' },
|
||||||
|
{ role: 'to', code: 'LHR' },
|
||||||
|
],
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('airtrailSync.buildSavePayload', () => {
|
||||||
|
it('round-trips the AirTrail-owned fields TREK does not model (issue #1240)', () => {
|
||||||
|
const payload = buildSavePayload(reservation(), existingFlight());
|
||||||
|
expect(payload).not.toBeNull();
|
||||||
|
expect(payload).toMatchObject({
|
||||||
|
takeoffActual: '2021-09-01',
|
||||||
|
takeoffActualTime: '19:12',
|
||||||
|
landingActual: '2021-09-02',
|
||||||
|
landingActualTime: '07:55',
|
||||||
|
departureTerminal: '7',
|
||||||
|
departureGate: 'B22',
|
||||||
|
arrivalTerminal: '5',
|
||||||
|
arrivalGate: 'A10',
|
||||||
|
customFields: { confirmation: 'ABC123' },
|
||||||
|
track: [{ lat: 40.6, lon: -73.7 }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still applies the TREK-owned edits on top of the preserved fields', () => {
|
||||||
|
const payload = buildSavePayload(
|
||||||
|
reservation({
|
||||||
|
reservation_time: '2021-09-01T20:30',
|
||||||
|
notes: 'changed in TREK',
|
||||||
|
metadata: JSON.stringify({ airline: 'BAW', flight_number: 'BA999', seat: '3C' }),
|
||||||
|
}),
|
||||||
|
existingFlight(),
|
||||||
|
);
|
||||||
|
expect(payload).toMatchObject({
|
||||||
|
id: 42,
|
||||||
|
from: 'JFK',
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
// The user's seat number is pushed onto their own AirTrail seat.
|
||||||
|
expect(payload?.seats[0].seatNumber).toBe('3C');
|
||||||
|
// …without disturbing the preserved AirTrail detail.
|
||||||
|
expect(payload?.departureTerminal).toBe('7');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves AirTrail aircraft/airline/reason when TREK metadata omits them (#1240)', () => {
|
||||||
|
// A TREK edit can drop these AirTrail-owned fields from metadata; the writeback
|
||||||
|
// must fall back to AirTrail's current values rather than nulling them.
|
||||||
|
const payload = buildSavePayload(reservation({ metadata: JSON.stringify({}) }), existingFlight());
|
||||||
|
expect(payload).toMatchObject({
|
||||||
|
airline: 'BAW', // entityCode(existing.airline) — icao preferred
|
||||||
|
aircraft: 'B772',
|
||||||
|
aircraftReg: 'G-VIIL',
|
||||||
|
flightReason: 'leisure',
|
||||||
|
flightNumber: 'BA178',
|
||||||
|
note: 'window seat',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the existing seat manifest rather than replacing it', () => {
|
||||||
|
const payload = buildSavePayload(
|
||||||
|
reservation({ metadata: JSON.stringify({}) }),
|
||||||
|
existingFlight({
|
||||||
|
seats: [
|
||||||
|
{ userId: 'u1', guestName: null, seat: 'window', seatNumber: '12A', seatClass: 'business' },
|
||||||
|
{ userId: null, guestName: 'Guest', seat: 'aisle', seatNumber: '12B', seatClass: 'business' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(payload?.seats).toHaveLength(2);
|
||||||
|
expect(payload?.seats[1]).toMatchObject({ guestName: 'Guest', seatNumber: '12B' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when an endpoint code is missing and no fallback exists', () => {
|
||||||
|
const payload = buildSavePayload(reservation({ endpoints: [] }), existingFlight({ from: airport({ iata: null, icao: null }) }));
|
||||||
|
expect(payload).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The #1240 write gate: pushReservationToAirtrail must NOT write to AirTrail unless
|
||||||
|
* the flight's owner has opted in (airtrail_write_enabled). Collaborators are mocked
|
||||||
|
* so the test exercises just the gate + payload wiring.
|
||||||
|
*/
|
||||||
|
|
||||||
|
vi.mock('../../../src/db/database', () => ({ db: { prepare: vi.fn() } }));
|
||||||
|
vi.mock('../../../src/services/adminService', () => ({ isAddonEnabled: vi.fn(() => true) }));
|
||||||
|
vi.mock('../../../src/services/auditLog', () => ({ logError: vi.fn(), logInfo: vi.fn() }));
|
||||||
|
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||||
|
vi.mock('../../../src/services/reservationService', () => ({
|
||||||
|
getReservation: vi.fn(),
|
||||||
|
getReservationWithJoins: vi.fn(),
|
||||||
|
updateReservation: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock('../../../src/services/airtrail/airtrailClient', () => ({
|
||||||
|
AirtrailAuthError: class AirtrailAuthError extends Error {},
|
||||||
|
getFlight: vi.fn(),
|
||||||
|
listFlights: vi.fn(),
|
||||||
|
saveFlight: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock('../../../src/services/airtrail/airtrailMapper', () => ({
|
||||||
|
canonicalHash: vi.fn(() => 'hash'),
|
||||||
|
mapFlightToReservation: vi.fn(() => ({})),
|
||||||
|
entityCode: (e: any) => e?.icao || e?.iata || null,
|
||||||
|
}));
|
||||||
|
vi.mock('../../../src/services/airtrail/airtrailService', () => ({
|
||||||
|
isAirtrailWriteEnabled: vi.fn(),
|
||||||
|
getAirtrailCredentials: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { pushReservationToAirtrail } from '../../../src/services/airtrail/airtrailSync';
|
||||||
|
import { db } from '../../../src/db/database';
|
||||||
|
import { getReservationWithJoins } from '../../../src/services/reservationService';
|
||||||
|
import { getFlight, saveFlight } from '../../../src/services/airtrail/airtrailClient';
|
||||||
|
import { isAirtrailWriteEnabled, getAirtrailCredentials } from '../../../src/services/airtrail/airtrailService';
|
||||||
|
|
||||||
|
const linkedRow = { id: 5, trip_id: 9, external_id: '42', external_owner_user_id: 7, sync_enabled: 1 };
|
||||||
|
const runSpy = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
// Route db reads: global sync setting + the linked reservation row.
|
||||||
|
(db.prepare as any).mockImplementation((sql: string) => ({
|
||||||
|
get: () => {
|
||||||
|
if (sql.includes('app_settings')) return { value: 'true' };
|
||||||
|
if (sql.includes('FROM reservations')) return { ...linkedRow };
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
run: (...args: any[]) => {
|
||||||
|
runSpy(sql, args);
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
all: () => [],
|
||||||
|
}));
|
||||||
|
(getAirtrailCredentials as any).mockReturnValue({ baseUrl: 'https://at.example', apiKey: 'k', allowInsecureTls: false });
|
||||||
|
// GET returns AirTrail-owned detail TREK doesn't model — must survive the writeback.
|
||||||
|
(getFlight as any).mockResolvedValue({ id: 42, from: { iata: 'JFK' }, to: { iata: 'LHR' }, seats: [], departureTerminal: '7' });
|
||||||
|
(saveFlight as any).mockResolvedValue({ id: 42 });
|
||||||
|
(getReservationWithJoins as any).mockReturnValue({
|
||||||
|
external_id: '42',
|
||||||
|
reservation_time: '2021-09-01T19:00',
|
||||||
|
reservation_end_time: '2021-09-02T08:00',
|
||||||
|
notes: 'note',
|
||||||
|
metadata: JSON.stringify({}),
|
||||||
|
endpoints: [
|
||||||
|
{ role: 'from', code: 'JFK' },
|
||||||
|
{ role: 'to', code: 'LHR' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pushReservationToAirtrail write gate (#1240)', () => {
|
||||||
|
it('does nothing — and does not detach — when the owner has not opted in', async () => {
|
||||||
|
(isAirtrailWriteEnabled as any).mockReturnValue(false);
|
||||||
|
await pushReservationToAirtrail(5, 9);
|
||||||
|
expect(getFlight).not.toHaveBeenCalled();
|
||||||
|
expect(saveFlight).not.toHaveBeenCalled();
|
||||||
|
expect(runSpy).not.toHaveBeenCalled(); // no detach, no hash write — pure no-op
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes back, preserving AirTrail-owned fields, when the owner has opted in', async () => {
|
||||||
|
(isAirtrailWriteEnabled as any).mockReturnValue(true);
|
||||||
|
await pushReservationToAirtrail(5, 9);
|
||||||
|
expect(saveFlight).toHaveBeenCalledTimes(1);
|
||||||
|
const payload = (saveFlight as any).mock.calls[0][1];
|
||||||
|
expect(payload.departureTerminal).toBe('7'); // spread preserved the unmanaged field
|
||||||
|
expect(payload.from).toBe('JFK'); // TREK-managed field still applied as a code
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Avoid any real DNS/network from the SSRF guard during saveSettings.
|
||||||
|
vi.mock('../../../src/utils/ssrfGuard', () => ({
|
||||||
|
checkSsrf: vi.fn(async () => ({ allowed: true, isPrivate: false })),
|
||||||
|
safeFetch: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { db } from '../../../src/db/database';
|
||||||
|
import { createUser } from '../../helpers/factories';
|
||||||
|
import {
|
||||||
|
getConnectionSettings,
|
||||||
|
isAirtrailWriteEnabled,
|
||||||
|
saveSettings,
|
||||||
|
} from '../../../src/services/airtrail/airtrailService';
|
||||||
|
|
||||||
|
describe('airtrail writeback opt-in persistence (#1240)', () => {
|
||||||
|
it('defaults the writeback opt-in to off for a new user', () => {
|
||||||
|
const { user } = createUser(db);
|
||||||
|
expect(isAirtrailWriteEnabled(user.id)).toBe(false);
|
||||||
|
expect(getConnectionSettings(user.id).writeEnabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists the opt-in and lets it be toggled back off without dropping the key', async () => {
|
||||||
|
const { user } = createUser(db);
|
||||||
|
|
||||||
|
await saveSettings(user.id, 'https://at.example.com', 'secret-key', false, true, null);
|
||||||
|
expect(isAirtrailWriteEnabled(user.id)).toBe(true);
|
||||||
|
const on = getConnectionSettings(user.id);
|
||||||
|
expect(on.writeEnabled).toBe(true);
|
||||||
|
expect(on.connected).toBe(true); // key stored
|
||||||
|
|
||||||
|
// No key supplied keeps the stored key; only the opt-in flips back off.
|
||||||
|
await saveSettings(user.id, 'https://at.example.com', undefined, false, false, null);
|
||||||
|
expect(isAirtrailWriteEnabled(user.id)).toBe(false);
|
||||||
|
expect(getConnectionSettings(user.id).connected).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import zlib from 'zlib';
|
||||||
|
|
||||||
|
// Data-integrity guard for the shipped Atlas region bundle. geoBoundaries fills
|
||||||
|
// shapeISO with the bare country code for some countries (every Spanish region got
|
||||||
|
// "ESP", every Chinese "CHN", also CL/OM), which made marking one region light up the
|
||||||
|
// whole country (#1217). build-atlas-geo.mjs now synthesizes a unique per-region code
|
||||||
|
// for those; this asserts the shipped bundle actually carries distinct codes.
|
||||||
|
describe('Atlas admin1 region bundle (#1217)', () => {
|
||||||
|
const bundlePath = path.join(__dirname, '..', '..', '..', 'assets', 'atlas', 'admin1.geojson.gz');
|
||||||
|
const features = JSON.parse(zlib.gunzipSync(fs.readFileSync(bundlePath)).toString()).features as {
|
||||||
|
properties: { iso_a2: string | null; iso_3166_2: string };
|
||||||
|
}[];
|
||||||
|
|
||||||
|
const regions = (a2: string) => features.filter(f => f.properties.iso_a2 === a2);
|
||||||
|
|
||||||
|
it('ATLAS-BUNDLE-001 — previously-broken countries now have distinct region codes', () => {
|
||||||
|
for (const a2 of ['ES', 'CN', 'CL', 'OM']) {
|
||||||
|
const f = regions(a2);
|
||||||
|
expect(f.length, `${a2} should ship regions`).toBeGreaterThan(1);
|
||||||
|
expect(new Set(f.map(r => r.properties.iso_3166_2)).size, `${a2} region codes must be unique`).toBe(f.length);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ATLAS-BUNDLE-002 — countries with real ISO codes keep them and stay unique', () => {
|
||||||
|
for (const a2 of ['DE', 'FR', 'US']) {
|
||||||
|
const f = regions(a2);
|
||||||
|
expect(f.length).toBeGreaterThan(1);
|
||||||
|
// real ISO 3166-2 form, e.g. DE-BW
|
||||||
|
expect(f.some(r => /^[A-Z]{2}-[A-Z0-9]+$/.test(r.properties.iso_3166_2))).toBe(true);
|
||||||
|
expect(new Set(f.map(r => r.properties.iso_3166_2)).size).toBe(f.length);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,7 +17,7 @@ const mockDb = vi.hoisted(() => {
|
|||||||
|
|
||||||
vi.mock('../../../src/db/database', () => mockDb);
|
vi.mock('../../../src/db/database', () => mockDb);
|
||||||
|
|
||||||
import { calculateSettlement } from '../../../src/services/budgetService';
|
import { calculateSettlement, updateSettlement } from '../../../src/services/budgetService';
|
||||||
import type { BudgetItem, BudgetItemMember, BudgetItemPayer } from '../../../src/types';
|
import type { BudgetItem, BudgetItemMember, BudgetItemPayer } from '../../../src/types';
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
@@ -189,4 +189,60 @@ describe('calculateSettlement', () => {
|
|||||||
expect(result.flows).toHaveLength(1);
|
expect(result.flows).toHaveLength(1);
|
||||||
expect(result.flows[0].amount).toBe(20);
|
expect(result.flows[0].amount).toBe(20);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('counts a settlement with no matching expense as an amount still to square up', () => {
|
||||||
|
// bob paid alice 30 but every expense behind it was deleted: alice now owes bob.
|
||||||
|
mockDb.db.prepare.mockImplementation((sql: string) => {
|
||||||
|
if (sql.includes('FROM budget_settlements')) {
|
||||||
|
return { all: vi.fn(() => [
|
||||||
|
{ id: 1, trip_id: 1, from_user_id: 2, to_user_id: 1, amount: 30, from_username: 'bob', to_username: 'alice', from_avatar: null, to_avatar: null },
|
||||||
|
]), get: vi.fn(), run: vi.fn() };
|
||||||
|
}
|
||||||
|
return { all: vi.fn(() => []), get: vi.fn(), run: vi.fn() };
|
||||||
|
});
|
||||||
|
const result = calculateSettlement(1);
|
||||||
|
const alice = result.balances.find(b => b.user_id === 1)!;
|
||||||
|
const bob = result.balances.find(b => b.user_id === 2)!;
|
||||||
|
expect(bob.balance).toBe(30);
|
||||||
|
expect(alice.balance).toBe(-30);
|
||||||
|
expect(result.flows).toEqual([
|
||||||
|
expect.objectContaining({ amount: 30, from: expect.objectContaining({ user_id: 1 }), to: expect.objectContaining({ user_id: 2 }) }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── updateSettlement ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('updateSettlement', () => {
|
||||||
|
it('returns null when the settlement is not in the trip', () => {
|
||||||
|
mockDb.db.prepare.mockImplementation((sql: string) => {
|
||||||
|
if (sql.includes('SELECT id FROM budget_settlements')) {
|
||||||
|
return { get: vi.fn(() => undefined), all: vi.fn(), run: vi.fn() };
|
||||||
|
}
|
||||||
|
return { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() };
|
||||||
|
});
|
||||||
|
expect(updateSettlement(7, 1, { from_user_id: 2, to_user_id: 1, amount: 10 })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates the row (rounded to cents) and returns the refreshed settlement', () => {
|
||||||
|
const run = vi.fn();
|
||||||
|
mockDb.db.prepare.mockImplementation((sql: string) => {
|
||||||
|
if (sql.includes('SELECT id FROM budget_settlements')) {
|
||||||
|
return { get: vi.fn(() => ({ id: 7 })), all: vi.fn(), run: vi.fn() };
|
||||||
|
}
|
||||||
|
if (sql.includes('UPDATE budget_settlements')) {
|
||||||
|
return { get: vi.fn(), all: vi.fn(), run };
|
||||||
|
}
|
||||||
|
if (sql.includes('FROM budget_settlements')) {
|
||||||
|
return { get: vi.fn(), all: vi.fn(() => [
|
||||||
|
{ id: 7, trip_id: 1, from_user_id: 2, to_user_id: 1, amount: 10.13, from_username: 'bob', to_username: 'alice', from_avatar: null, to_avatar: null },
|
||||||
|
]), run: vi.fn() };
|
||||||
|
}
|
||||||
|
return { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() };
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = updateSettlement(7, 1, { from_user_id: 2, to_user_id: 1, amount: 10.126 });
|
||||||
|
expect(run).toHaveBeenCalledWith(2, 1, 10.13, 7);
|
||||||
|
expect(res).toMatchObject({ id: 7, from_user_id: 2, to_user_id: 1, amount: 10.13 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -332,6 +332,41 @@ describe('resolveGoogleMapsUrl coordinate extraction (ReDoS guards)', () => {
|
|||||||
expect(result.name).toBe('Eiffel Tower');
|
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', () => {
|
it('MAPS-024 (ReDoS): /@(-?\\d+\\.?\\d*),(-?\\d+\\.?\\d*)/ on adversarial input < 500ms', () => {
|
||||||
const adversarial = '/@' + '1'.repeat(10000) + '.';
|
const adversarial = '/@' + '1'.repeat(10000) + '.';
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
|
|||||||
@@ -397,6 +397,46 @@ describe('exportICS', () => {
|
|||||||
|
|
||||||
expect(ics).toContain('DTEND:20250602T160000');
|
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 ──────────────────────────────────────────
|
// ── deleteOldCover — path containment ──────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"printWidth": 120,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"trailingComma": "all",
|
"trailingComma": "all",
|
||||||
"plugins": [
|
"plugins": [
|
||||||
|
|||||||
@@ -16,16 +16,9 @@ describe('adminUserCreateRequestSchema', () => {
|
|||||||
role: 'admin',
|
role: 'admin',
|
||||||
}).success,
|
}).success,
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(
|
expect(adminUserCreateRequestSchema.safeParse({ email: 'a@b.c' }).success).toBe(true);
|
||||||
adminUserCreateRequestSchema.safeParse({ email: 'a@b.c' }).success,
|
expect(adminUserCreateRequestSchema.safeParse({ password: 'p' }).success).toBe(false);
|
||||||
).toBe(true);
|
expect(adminUserCreateRequestSchema.safeParse({ email: 'a@b.c', role: 'root' }).success).toBe(false);
|
||||||
expect(
|
|
||||||
adminUserCreateRequestSchema.safeParse({ password: 'p' }).success,
|
|
||||||
).toBe(false);
|
|
||||||
expect(
|
|
||||||
adminUserCreateRequestSchema.safeParse({ email: 'a@b.c', role: 'root' })
|
|
||||||
.success,
|
|
||||||
).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -49,19 +42,13 @@ describe('adminInviteCreateRequestSchema', () => {
|
|||||||
}).success,
|
}).success,
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(adminInviteCreateRequestSchema.safeParse({}).success).toBe(true);
|
expect(adminInviteCreateRequestSchema.safeParse({}).success).toBe(true);
|
||||||
expect(
|
expect(adminInviteCreateRequestSchema.safeParse({ role: 'root' }).success).toBe(false);
|
||||||
adminInviteCreateRequestSchema.safeParse({ role: 'root' }).success,
|
|
||||||
).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('adminFeatureToggleRequestSchema', () => {
|
describe('adminFeatureToggleRequestSchema', () => {
|
||||||
it('requires a boolean enabled', () => {
|
it('requires a boolean enabled', () => {
|
||||||
expect(
|
expect(adminFeatureToggleRequestSchema.safeParse({ enabled: true }).success).toBe(true);
|
||||||
adminFeatureToggleRequestSchema.safeParse({ enabled: true }).success,
|
expect(adminFeatureToggleRequestSchema.safeParse({ enabled: 'yes' }).success).toBe(false);
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
adminFeatureToggleRequestSchema.safeParse({ enabled: 'yes' }).success,
|
|
||||||
).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,29 +14,21 @@ export const adminUserCreateRequestSchema = z.object({
|
|||||||
username: z.string().optional(),
|
username: z.string().optional(),
|
||||||
role: z.enum(['user', 'admin']).optional(),
|
role: z.enum(['user', 'admin']).optional(),
|
||||||
});
|
});
|
||||||
export type AdminUserCreateRequest = z.infer<
|
export type AdminUserCreateRequest = z.infer<typeof adminUserCreateRequestSchema>;
|
||||||
typeof adminUserCreateRequestSchema
|
|
||||||
>;
|
|
||||||
|
|
||||||
export const adminPermissionsRequestSchema = z.object({
|
export const adminPermissionsRequestSchema = z.object({
|
||||||
permissions: z.record(z.string(), z.unknown()),
|
permissions: z.record(z.string(), z.unknown()),
|
||||||
});
|
});
|
||||||
export type AdminPermissionsRequest = z.infer<
|
export type AdminPermissionsRequest = z.infer<typeof adminPermissionsRequestSchema>;
|
||||||
typeof adminPermissionsRequestSchema
|
|
||||||
>;
|
|
||||||
|
|
||||||
export const adminInviteCreateRequestSchema = z.object({
|
export const adminInviteCreateRequestSchema = z.object({
|
||||||
max_uses: z.number().optional(),
|
max_uses: z.number().optional(),
|
||||||
expires_in_days: z.number().optional(),
|
expires_in_days: z.number().optional(),
|
||||||
role: z.enum(['user', 'admin']).optional(),
|
role: z.enum(['user', 'admin']).optional(),
|
||||||
});
|
});
|
||||||
export type AdminInviteCreateRequest = z.infer<
|
export type AdminInviteCreateRequest = z.infer<typeof adminInviteCreateRequestSchema>;
|
||||||
typeof adminInviteCreateRequestSchema
|
|
||||||
>;
|
|
||||||
|
|
||||||
export const adminFeatureToggleRequestSchema = z.object({
|
export const adminFeatureToggleRequestSchema = z.object({
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
});
|
});
|
||||||
export type AdminFeatureToggleRequest = z.infer<
|
export type AdminFeatureToggleRequest = z.infer<typeof adminFeatureToggleRequestSchema>;
|
||||||
typeof adminFeatureToggleRequestSchema
|
|
||||||
>;
|
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ export const airtrailSettingsSchema = z.object({
|
|||||||
apiKey: z.string().max(512).optional(),
|
apiKey: z.string().max(512).optional(),
|
||||||
/** Allow self-signed TLS certs (common on LAN instances). */
|
/** Allow self-signed TLS certs (common on LAN instances). */
|
||||||
allowInsecureTls: z.boolean().optional().default(false),
|
allowInsecureTls: z.boolean().optional().default(false),
|
||||||
|
/**
|
||||||
|
* Opt in to writing TREK edits back to AirTrail (#1240). Off by default:
|
||||||
|
* AirTrail is the source of truth and TREK only reads from it.
|
||||||
|
*/
|
||||||
|
writeEnabled: z.boolean().optional().default(false),
|
||||||
});
|
});
|
||||||
export type AirtrailSettings = z.infer<typeof airtrailSettingsSchema>;
|
export type AirtrailSettings = z.infer<typeof airtrailSettingsSchema>;
|
||||||
|
|
||||||
@@ -28,6 +33,7 @@ export const airtrailConnectionSchema = z.object({
|
|||||||
url: z.string(),
|
url: z.string(),
|
||||||
apiKeyMasked: z.string(),
|
apiKeyMasked: z.string(),
|
||||||
allowInsecureTls: z.boolean(),
|
allowInsecureTls: z.boolean(),
|
||||||
|
writeEnabled: z.boolean(),
|
||||||
connected: z.boolean(),
|
connected: z.boolean(),
|
||||||
});
|
});
|
||||||
export type AirtrailConnection = z.infer<typeof airtrailConnectionSchema>;
|
export type AirtrailConnection = z.infer<typeof airtrailConnectionSchema>;
|
||||||
|
|||||||
@@ -8,38 +8,23 @@ import { describe, it, expect } from 'vitest';
|
|||||||
|
|
||||||
describe('assignmentCreateRequestSchema', () => {
|
describe('assignmentCreateRequestSchema', () => {
|
||||||
it('requires a place_id; notes optional/nullable', () => {
|
it('requires a place_id; notes optional/nullable', () => {
|
||||||
expect(
|
expect(assignmentCreateRequestSchema.safeParse({ place_id: 2 }).success).toBe(true);
|
||||||
assignmentCreateRequestSchema.safeParse({ place_id: 2 }).success,
|
expect(assignmentCreateRequestSchema.safeParse({ place_id: '2', notes: null }).success).toBe(true);
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
assignmentCreateRequestSchema.safeParse({ place_id: '2', notes: null })
|
|
||||||
.success,
|
|
||||||
).toBe(true);
|
|
||||||
expect(assignmentCreateRequestSchema.safeParse({}).success).toBe(false);
|
expect(assignmentCreateRequestSchema.safeParse({}).success).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('assignmentMoveRequestSchema', () => {
|
describe('assignmentMoveRequestSchema', () => {
|
||||||
it('requires new_day_id; order_index optional', () => {
|
it('requires new_day_id; order_index optional', () => {
|
||||||
expect(
|
expect(assignmentMoveRequestSchema.safeParse({ new_day_id: 4 }).success).toBe(true);
|
||||||
assignmentMoveRequestSchema.safeParse({ new_day_id: 4 }).success,
|
expect(assignmentMoveRequestSchema.safeParse({ new_day_id: 4, order_index: 0 }).success).toBe(true);
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
assignmentMoveRequestSchema.safeParse({ new_day_id: 4, order_index: 0 })
|
|
||||||
.success,
|
|
||||||
).toBe(true);
|
|
||||||
expect(assignmentMoveRequestSchema.safeParse({}).success).toBe(false);
|
expect(assignmentMoveRequestSchema.safeParse({}).success).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('assignmentParticipantsRequestSchema', () => {
|
describe('assignmentParticipantsRequestSchema', () => {
|
||||||
it('requires a numeric user_ids array', () => {
|
it('requires a numeric user_ids array', () => {
|
||||||
expect(
|
expect(assignmentParticipantsRequestSchema.safeParse({ user_ids: [1, 2] }).success).toBe(true);
|
||||||
assignmentParticipantsRequestSchema.safeParse({ user_ids: [1, 2] })
|
expect(assignmentParticipantsRequestSchema.safeParse({ user_ids: 'no' }).success).toBe(false);
|
||||||
.success,
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
assignmentParticipantsRequestSchema.safeParse({ user_ids: 'no' }).success,
|
|
||||||
).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,16 +49,12 @@ export const assignmentCreateRequestSchema = z.object({
|
|||||||
place_id: z.union([z.number(), z.string()]),
|
place_id: z.union([z.number(), z.string()]),
|
||||||
notes: z.string().nullable().optional(),
|
notes: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
export type AssignmentCreateRequest = z.infer<
|
export type AssignmentCreateRequest = z.infer<typeof assignmentCreateRequestSchema>;
|
||||||
typeof assignmentCreateRequestSchema
|
|
||||||
>;
|
|
||||||
|
|
||||||
export const assignmentReorderRequestSchema = z.object({
|
export const assignmentReorderRequestSchema = z.object({
|
||||||
orderedIds: z.array(z.number()),
|
orderedIds: z.array(z.number()),
|
||||||
});
|
});
|
||||||
export type AssignmentReorderRequest = z.infer<
|
export type AssignmentReorderRequest = z.infer<typeof assignmentReorderRequestSchema>;
|
||||||
typeof assignmentReorderRequestSchema
|
|
||||||
>;
|
|
||||||
|
|
||||||
export const assignmentMoveRequestSchema = z.object({
|
export const assignmentMoveRequestSchema = z.object({
|
||||||
new_day_id: z.union([z.number(), z.string()]),
|
new_day_id: z.union([z.number(), z.string()]),
|
||||||
@@ -75,6 +71,4 @@ export type AssignmentTimeRequest = z.infer<typeof assignmentTimeRequestSchema>;
|
|||||||
export const assignmentParticipantsRequestSchema = z.object({
|
export const assignmentParticipantsRequestSchema = z.object({
|
||||||
user_ids: z.array(z.number()),
|
user_ids: z.array(z.number()),
|
||||||
});
|
});
|
||||||
export type AssignmentParticipantsRequest = z.infer<
|
export type AssignmentParticipantsRequest = z.infer<typeof assignmentParticipantsRequestSchema>;
|
||||||
typeof assignmentParticipantsRequestSchema
|
|
||||||
>;
|
|
||||||
|
|||||||
@@ -1,28 +1,17 @@
|
|||||||
import {
|
import { markRegionRequestSchema, createBucketItemRequestSchema, regionGeoSchema } from './atlas.schema';
|
||||||
markRegionRequestSchema,
|
|
||||||
createBucketItemRequestSchema,
|
|
||||||
regionGeoSchema,
|
|
||||||
} from './atlas.schema';
|
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
describe('markRegionRequestSchema', () => {
|
describe('markRegionRequestSchema', () => {
|
||||||
it('requires both name and country_code', () => {
|
it('requires both name and country_code', () => {
|
||||||
expect(
|
expect(markRegionRequestSchema.safeParse({ name: 'Bavaria', country_code: 'DE' }).success).toBe(true);
|
||||||
markRegionRequestSchema.safeParse({ name: 'Bavaria', country_code: 'DE' })
|
expect(markRegionRequestSchema.safeParse({ name: 'Bavaria' }).success).toBe(false);
|
||||||
.success,
|
|
||||||
).toBe(true);
|
|
||||||
expect(markRegionRequestSchema.safeParse({ name: 'Bavaria' }).success).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createBucketItemRequestSchema', () => {
|
describe('createBucketItemRequestSchema', () => {
|
||||||
it('requires a name; coordinates and metadata optional/nullable', () => {
|
it('requires a name; coordinates and metadata optional/nullable', () => {
|
||||||
expect(
|
expect(createBucketItemRequestSchema.safeParse({ name: 'Tokyo' }).success).toBe(true);
|
||||||
createBucketItemRequestSchema.safeParse({ name: 'Tokyo' }).success,
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
expect(
|
||||||
createBucketItemRequestSchema.safeParse({
|
createBucketItemRequestSchema.safeParse({
|
||||||
name: 'Tokyo',
|
name: 'Tokyo',
|
||||||
@@ -37,18 +26,13 @@ describe('createBucketItemRequestSchema', () => {
|
|||||||
|
|
||||||
describe('regionGeoSchema', () => {
|
describe('regionGeoSchema', () => {
|
||||||
it('accepts a FeatureCollection with opaque features', () => {
|
it('accepts a FeatureCollection with opaque features', () => {
|
||||||
expect(
|
expect(regionGeoSchema.safeParse({ type: 'FeatureCollection', features: [] }).success).toBe(true);
|
||||||
regionGeoSchema.safeParse({ type: 'FeatureCollection', features: [] })
|
|
||||||
.success,
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
expect(
|
||||||
regionGeoSchema.safeParse({
|
regionGeoSchema.safeParse({
|
||||||
type: 'FeatureCollection',
|
type: 'FeatureCollection',
|
||||||
features: [{ anything: true }],
|
features: [{ anything: true }],
|
||||||
}).success,
|
}).success,
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(
|
expect(regionGeoSchema.safeParse({ type: 'Other', features: [] }).success).toBe(false);
|
||||||
regionGeoSchema.safeParse({ type: 'Other', features: [] }).success,
|
|
||||||
).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,9 +29,7 @@ export const createBucketItemRequestSchema = z.object({
|
|||||||
notes: z.string().nullable().optional(),
|
notes: z.string().nullable().optional(),
|
||||||
target_date: z.string().nullable().optional(),
|
target_date: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
export type CreateBucketItemRequest = z.infer<
|
export type CreateBucketItemRequest = z.infer<typeof createBucketItemRequestSchema>;
|
||||||
typeof createBucketItemRequestSchema
|
|
||||||
>;
|
|
||||||
|
|
||||||
export const updateBucketItemRequestSchema = z.object({
|
export const updateBucketItemRequestSchema = z.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
@@ -41,9 +39,7 @@ export const updateBucketItemRequestSchema = z.object({
|
|||||||
country_code: z.string().nullable().optional(),
|
country_code: z.string().nullable().optional(),
|
||||||
target_date: z.string().nullable().optional(),
|
target_date: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
export type UpdateBucketItemRequest = z.infer<
|
export type UpdateBucketItemRequest = z.infer<typeof updateBucketItemRequestSchema>;
|
||||||
typeof updateBucketItemRequestSchema
|
|
||||||
>;
|
|
||||||
|
|
||||||
/** A bucket-list item row (DB-shaped; kept open). */
|
/** A bucket-list item row (DB-shaped; kept open). */
|
||||||
export const bucketItemSchema = open;
|
export const bucketItemSchema = open;
|
||||||
@@ -59,3 +55,178 @@ export const regionGeoSchema = z.object({
|
|||||||
features: z.array(z.unknown()),
|
features: z.array(z.unknown()),
|
||||||
});
|
});
|
||||||
export type RegionGeo = z.infer<typeof regionGeoSchema>;
|
export type RegionGeo = z.infer<typeof regionGeoSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ISO 3166-1 alpha-2 country code → continent. Single source of truth for the
|
||||||
|
* Atlas continent breakdown, used by the server (stats aggregation) and the
|
||||||
|
* client (keeping the per-continent counts in sync on optimistic mark/unmark).
|
||||||
|
*/
|
||||||
|
export const CONTINENT_MAP: Record<string, string> = {
|
||||||
|
AF: 'Asia',
|
||||||
|
AL: 'Europe',
|
||||||
|
DZ: 'Africa',
|
||||||
|
AD: 'Europe',
|
||||||
|
AO: 'Africa',
|
||||||
|
AR: 'South America',
|
||||||
|
AM: 'Asia',
|
||||||
|
AU: 'Oceania',
|
||||||
|
AT: 'Europe',
|
||||||
|
AZ: 'Asia',
|
||||||
|
BA: 'Europe',
|
||||||
|
BD: 'Asia',
|
||||||
|
BF: 'Africa',
|
||||||
|
BH: 'Asia',
|
||||||
|
BI: 'Africa',
|
||||||
|
BJ: 'Africa',
|
||||||
|
BN: 'Asia',
|
||||||
|
BO: 'South America',
|
||||||
|
BR: 'South America',
|
||||||
|
BE: 'Europe',
|
||||||
|
BG: 'Europe',
|
||||||
|
BW: 'Africa',
|
||||||
|
CA: 'North America',
|
||||||
|
CD: 'Africa',
|
||||||
|
CG: 'Africa',
|
||||||
|
CI: 'Africa',
|
||||||
|
CL: 'South America',
|
||||||
|
CM: 'Africa',
|
||||||
|
CN: 'Asia',
|
||||||
|
CO: 'South America',
|
||||||
|
CR: 'North America',
|
||||||
|
CU: 'North America',
|
||||||
|
CV: 'Africa',
|
||||||
|
CY: 'Europe',
|
||||||
|
HR: 'Europe',
|
||||||
|
CZ: 'Europe',
|
||||||
|
DJ: 'Africa',
|
||||||
|
DK: 'Europe',
|
||||||
|
DO: 'North America',
|
||||||
|
EC: 'South America',
|
||||||
|
EG: 'Africa',
|
||||||
|
EE: 'Europe',
|
||||||
|
ER: 'Africa',
|
||||||
|
ET: 'Africa',
|
||||||
|
FI: 'Europe',
|
||||||
|
FR: 'Europe',
|
||||||
|
DE: 'Europe',
|
||||||
|
GE: 'Asia',
|
||||||
|
GH: 'Africa',
|
||||||
|
GN: 'Africa',
|
||||||
|
GR: 'Europe',
|
||||||
|
GT: 'North America',
|
||||||
|
HN: 'North America',
|
||||||
|
HT: 'North America',
|
||||||
|
HU: 'Europe',
|
||||||
|
IS: 'Europe',
|
||||||
|
IN: 'Asia',
|
||||||
|
ID: 'Asia',
|
||||||
|
IR: 'Asia',
|
||||||
|
IQ: 'Asia',
|
||||||
|
IE: 'Europe',
|
||||||
|
IL: 'Asia',
|
||||||
|
IT: 'Europe',
|
||||||
|
JM: 'North America',
|
||||||
|
JO: 'Asia',
|
||||||
|
JP: 'Asia',
|
||||||
|
KE: 'Africa',
|
||||||
|
KG: 'Asia',
|
||||||
|
KH: 'Asia',
|
||||||
|
KR: 'Asia',
|
||||||
|
KW: 'Asia',
|
||||||
|
KZ: 'Asia',
|
||||||
|
LA: 'Asia',
|
||||||
|
LB: 'Asia',
|
||||||
|
LK: 'Asia',
|
||||||
|
LV: 'Europe',
|
||||||
|
LT: 'Europe',
|
||||||
|
LU: 'Europe',
|
||||||
|
LY: 'Africa',
|
||||||
|
MA: 'Africa',
|
||||||
|
MD: 'Europe',
|
||||||
|
ME: 'Europe',
|
||||||
|
MG: 'Africa',
|
||||||
|
MK: 'Europe',
|
||||||
|
ML: 'Africa',
|
||||||
|
MM: 'Asia',
|
||||||
|
MN: 'Asia',
|
||||||
|
MR: 'Africa',
|
||||||
|
MT: 'Europe',
|
||||||
|
MU: 'Africa',
|
||||||
|
MV: 'Asia',
|
||||||
|
MW: 'Africa',
|
||||||
|
MY: 'Asia',
|
||||||
|
MX: 'North America',
|
||||||
|
MZ: 'Africa',
|
||||||
|
NA: 'Africa',
|
||||||
|
NE: 'Africa',
|
||||||
|
NI: 'North America',
|
||||||
|
NL: 'Europe',
|
||||||
|
NP: 'Asia',
|
||||||
|
NZ: 'Oceania',
|
||||||
|
NO: 'Europe',
|
||||||
|
OM: 'Asia',
|
||||||
|
PA: 'North America',
|
||||||
|
PG: 'Oceania',
|
||||||
|
PK: 'Asia',
|
||||||
|
PE: 'South America',
|
||||||
|
PH: 'Asia',
|
||||||
|
PL: 'Europe',
|
||||||
|
PS: 'Asia',
|
||||||
|
PT: 'Europe',
|
||||||
|
PY: 'South America',
|
||||||
|
QA: 'Asia',
|
||||||
|
RO: 'Europe',
|
||||||
|
RU: 'Europe',
|
||||||
|
RW: 'Africa',
|
||||||
|
SA: 'Asia',
|
||||||
|
SC: 'Africa',
|
||||||
|
SD: 'Africa',
|
||||||
|
SG: 'Asia',
|
||||||
|
SI: 'Europe',
|
||||||
|
SK: 'Europe',
|
||||||
|
SN: 'Africa',
|
||||||
|
SO: 'Africa',
|
||||||
|
RS: 'Europe',
|
||||||
|
SV: 'North America',
|
||||||
|
SY: 'Asia',
|
||||||
|
TG: 'Africa',
|
||||||
|
TJ: 'Asia',
|
||||||
|
TM: 'Asia',
|
||||||
|
TN: 'Africa',
|
||||||
|
TT: 'North America',
|
||||||
|
TW: 'Asia',
|
||||||
|
TZ: 'Africa',
|
||||||
|
ZA: 'Africa',
|
||||||
|
SE: 'Europe',
|
||||||
|
CH: 'Europe',
|
||||||
|
TH: 'Asia',
|
||||||
|
TR: 'Europe',
|
||||||
|
UA: 'Europe',
|
||||||
|
UG: 'Africa',
|
||||||
|
UY: 'South America',
|
||||||
|
UZ: 'Asia',
|
||||||
|
VE: 'South America',
|
||||||
|
AE: 'Asia',
|
||||||
|
GB: 'Europe',
|
||||||
|
US: 'North America',
|
||||||
|
VN: 'Asia',
|
||||||
|
XK: 'Europe',
|
||||||
|
YE: 'Asia',
|
||||||
|
ZM: 'Africa',
|
||||||
|
ZW: 'Africa',
|
||||||
|
NG: 'Africa',
|
||||||
|
HK: 'Asia',
|
||||||
|
MO: 'Asia',
|
||||||
|
SM: 'Europe',
|
||||||
|
VA: 'Europe',
|
||||||
|
MC: 'Europe',
|
||||||
|
LI: 'Europe',
|
||||||
|
GI: 'Europe',
|
||||||
|
PR: 'North America',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Continent for an ISO alpha-2 country code; 'Other' when unknown. */
|
||||||
|
export function continentForCountry(code: string | null | undefined): string {
|
||||||
|
if (!code) return 'Other';
|
||||||
|
return CONTINENT_MAP[code.toUpperCase()] || 'Other';
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,10 +13,7 @@ import { describe, it, expect } from 'vitest';
|
|||||||
|
|
||||||
describe('registerRequestSchema', () => {
|
describe('registerRequestSchema', () => {
|
||||||
it('requires email + password; username/invite optional', () => {
|
it('requires email + password; username/invite optional', () => {
|
||||||
expect(
|
expect(registerRequestSchema.safeParse({ email: 'a@b.c', password: 'pw' }).success).toBe(true);
|
||||||
registerRequestSchema.safeParse({ email: 'a@b.c', password: 'pw' })
|
|
||||||
.success,
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
expect(
|
||||||
registerRequestSchema.safeParse({
|
registerRequestSchema.safeParse({
|
||||||
email: 'a@b.c',
|
email: 'a@b.c',
|
||||||
@@ -24,32 +21,21 @@ describe('registerRequestSchema', () => {
|
|||||||
invite_token: 't',
|
invite_token: 't',
|
||||||
}).success,
|
}).success,
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(registerRequestSchema.safeParse({ email: 'a@b.c' }).success).toBe(
|
expect(registerRequestSchema.safeParse({ email: 'a@b.c' }).success).toBe(false);
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('loginRequestSchema', () => {
|
describe('loginRequestSchema', () => {
|
||||||
it('requires email + password', () => {
|
it('requires email + password', () => {
|
||||||
expect(
|
expect(loginRequestSchema.safeParse({ email: 'a@b.c', password: 'pw' }).success).toBe(true);
|
||||||
loginRequestSchema.safeParse({ email: 'a@b.c', password: 'pw' }).success,
|
expect(loginRequestSchema.safeParse({ email: 'a@b.c' }).success).toBe(false);
|
||||||
).toBe(true);
|
|
||||||
expect(loginRequestSchema.safeParse({ email: 'a@b.c' }).success).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('forgot/reset/change password schemas', () => {
|
describe('forgot/reset/change password schemas', () => {
|
||||||
it('validate their required fields', () => {
|
it('validate their required fields', () => {
|
||||||
expect(
|
expect(forgotPasswordRequestSchema.safeParse({ email: 'a@b.c' }).success).toBe(true);
|
||||||
forgotPasswordRequestSchema.safeParse({ email: 'a@b.c' }).success,
|
expect(resetPasswordRequestSchema.safeParse({ token: 't', new_password: 'pw' }).success).toBe(true);
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
resetPasswordRequestSchema.safeParse({ token: 't', new_password: 'pw' })
|
|
||||||
.success,
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
expect(
|
||||||
resetPasswordRequestSchema.safeParse({
|
resetPasswordRequestSchema.safeParse({
|
||||||
token: 't',
|
token: 't',
|
||||||
@@ -57,36 +43,23 @@ describe('forgot/reset/change password schemas', () => {
|
|||||||
mfa_code: '123456',
|
mfa_code: '123456',
|
||||||
}).success,
|
}).success,
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(
|
expect(resetPasswordRequestSchema.safeParse({ new_password: 'pw' }).success).toBe(false);
|
||||||
resetPasswordRequestSchema.safeParse({ new_password: 'pw' }).success,
|
|
||||||
).toBe(false);
|
|
||||||
expect(
|
expect(
|
||||||
changePasswordRequestSchema.safeParse({
|
changePasswordRequestSchema.safeParse({
|
||||||
current_password: 'a',
|
current_password: 'a',
|
||||||
new_password: 'b',
|
new_password: 'b',
|
||||||
}).success,
|
}).success,
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(
|
expect(changePasswordRequestSchema.safeParse({ new_password: 'b' }).success).toBe(false);
|
||||||
changePasswordRequestSchema.safeParse({ new_password: 'b' }).success,
|
|
||||||
).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('mfa + mcp-token schemas', () => {
|
describe('mfa + mcp-token schemas', () => {
|
||||||
it('validate their fields', () => {
|
it('validate their fields', () => {
|
||||||
expect(
|
expect(mfaVerifyLoginRequestSchema.safeParse({ mfa_token: 't', code: '123456' }).success).toBe(true);
|
||||||
mfaVerifyLoginRequestSchema.safeParse({ mfa_token: 't', code: '123456' })
|
expect(mfaVerifyLoginRequestSchema.safeParse({ mfa_token: 't' }).success).toBe(false);
|
||||||
.success,
|
expect(mfaEnableRequestSchema.safeParse({ code: '123456' }).success).toBe(true);
|
||||||
).toBe(true);
|
expect(mcpTokenCreateRequestSchema.safeParse({ name: 'CLI' }).success).toBe(true);
|
||||||
expect(
|
|
||||||
mfaVerifyLoginRequestSchema.safeParse({ mfa_token: 't' }).success,
|
|
||||||
).toBe(false);
|
|
||||||
expect(mfaEnableRequestSchema.safeParse({ code: '123456' }).success).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect(mcpTokenCreateRequestSchema.safeParse({ name: 'CLI' }).success).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect(mcpTokenCreateRequestSchema.safeParse({}).success).toBe(true);
|
expect(mcpTokenCreateRequestSchema.safeParse({}).success).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,16 +11,11 @@ describe('autoBackupSettingsRequestSchema', () => {
|
|||||||
keep_days: 7,
|
keep_days: 7,
|
||||||
}).success,
|
}).success,
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(
|
expect(autoBackupSettingsRequestSchema.safeParse({ enabled: false, foo: 'bar' }).success).toBe(true);
|
||||||
autoBackupSettingsRequestSchema.safeParse({ enabled: false, foo: 'bar' })
|
|
||||||
.success,
|
|
||||||
).toBe(true);
|
|
||||||
expect(autoBackupSettingsRequestSchema.safeParse({}).success).toBe(true);
|
expect(autoBackupSettingsRequestSchema.safeParse({}).success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a non-boolean enabled', () => {
|
it('rejects a non-boolean enabled', () => {
|
||||||
expect(
|
expect(autoBackupSettingsRequestSchema.safeParse({ enabled: 'yes' }).success).toBe(false);
|
||||||
autoBackupSettingsRequestSchema.safeParse({ enabled: 'yes' }).success,
|
|
||||||
).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,4 @@ export const autoBackupSettingsRequestSchema = z
|
|||||||
time: z.string().optional(),
|
time: z.string().optional(),
|
||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
export type AutoBackupSettingsRequest = z.infer<
|
export type AutoBackupSettingsRequest = z.infer<typeof autoBackupSettingsRequestSchema>;
|
||||||
typeof autoBackupSettingsRequestSchema
|
|
||||||
>;
|
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ import { describe, it, expect } from 'vitest';
|
|||||||
|
|
||||||
describe('budgetCreateItemRequestSchema', () => {
|
describe('budgetCreateItemRequestSchema', () => {
|
||||||
it('requires a name; money/meta fields optional + nullable', () => {
|
it('requires a name; money/meta fields optional + nullable', () => {
|
||||||
expect(
|
expect(budgetCreateItemRequestSchema.safeParse({ name: 'Hotel' }).success).toBe(true);
|
||||||
budgetCreateItemRequestSchema.safeParse({ name: 'Hotel' }).success,
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
expect(
|
||||||
budgetCreateItemRequestSchema.safeParse({
|
budgetCreateItemRequestSchema.safeParse({
|
||||||
name: 'Hotel',
|
name: 'Hotel',
|
||||||
@@ -25,34 +23,21 @@ describe('budgetCreateItemRequestSchema', () => {
|
|||||||
|
|
||||||
describe('budgetUpdateMembersRequestSchema', () => {
|
describe('budgetUpdateMembersRequestSchema', () => {
|
||||||
it('requires a numeric user_ids array', () => {
|
it('requires a numeric user_ids array', () => {
|
||||||
expect(
|
expect(budgetUpdateMembersRequestSchema.safeParse({ user_ids: [1, 2] }).success).toBe(true);
|
||||||
budgetUpdateMembersRequestSchema.safeParse({ user_ids: [1, 2] }).success,
|
expect(budgetUpdateMembersRequestSchema.safeParse({ user_ids: 'no' }).success).toBe(false);
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
budgetUpdateMembersRequestSchema.safeParse({ user_ids: 'no' }).success,
|
|
||||||
).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('budgetToggleMemberPaidRequestSchema', () => {
|
describe('budgetToggleMemberPaidRequestSchema', () => {
|
||||||
it('requires a boolean paid', () => {
|
it('requires a boolean paid', () => {
|
||||||
expect(
|
expect(budgetToggleMemberPaidRequestSchema.safeParse({ paid: true }).success).toBe(true);
|
||||||
budgetToggleMemberPaidRequestSchema.safeParse({ paid: true }).success,
|
expect(budgetToggleMemberPaidRequestSchema.safeParse({ paid: 'yes' }).success).toBe(false);
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
budgetToggleMemberPaidRequestSchema.safeParse({ paid: 'yes' }).success,
|
|
||||||
).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('budgetReorderItemsRequestSchema', () => {
|
describe('budgetReorderItemsRequestSchema', () => {
|
||||||
it('requires numeric ids', () => {
|
it('requires numeric ids', () => {
|
||||||
expect(
|
expect(budgetReorderItemsRequestSchema.safeParse({ orderedIds: [3, 1, 2] }).success).toBe(true);
|
||||||
budgetReorderItemsRequestSchema.safeParse({ orderedIds: [3, 1, 2] })
|
expect(budgetReorderItemsRequestSchema.safeParse({ orderedIds: ['a'] }).success).toBe(false);
|
||||||
.success,
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
budgetReorderItemsRequestSchema.safeParse({ orderedIds: ['a'] }).success,
|
|
||||||
).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,6 +49,35 @@ export const COST_CATEGORIES = [
|
|||||||
] as const;
|
] as const;
|
||||||
export type CostCategory = (typeof COST_CATEGORIES)[number];
|
export type CostCategory = (typeof COST_CATEGORIES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a reservation `type` (flight, train, hotel, …) to one of the fixed Costs
|
||||||
|
* categories, so an expense created from a booking lands in the right bucket
|
||||||
|
* instead of a free-text/localized label. Unknown types fall back to `other`.
|
||||||
|
*/
|
||||||
|
const RESERVATION_TYPE_TO_COST_CATEGORY: Record<string, CostCategory> = {
|
||||||
|
flight: 'flights',
|
||||||
|
plane: 'flights',
|
||||||
|
train: 'transport',
|
||||||
|
bus: 'transport',
|
||||||
|
car: 'transport',
|
||||||
|
'car-rental': 'transport',
|
||||||
|
ferry: 'transport',
|
||||||
|
boat: 'transport',
|
||||||
|
taxi: 'transport',
|
||||||
|
transfer: 'transport',
|
||||||
|
transport: 'transport',
|
||||||
|
hotel: 'accommodation',
|
||||||
|
accommodation: 'accommodation',
|
||||||
|
lodging: 'accommodation',
|
||||||
|
restaurant: 'food',
|
||||||
|
activity: 'activities',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function typeToCostCategory(type: string | null | undefined): CostCategory {
|
||||||
|
if (!type) return 'other';
|
||||||
|
return RESERVATION_TYPE_TO_COST_CATEGORY[type.trim().toLowerCase()] || 'other';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One payer of an expense — a row of budget_item_payers. `amount` is in the
|
* One payer of an expense — a row of budget_item_payers. `amount` is in the
|
||||||
* expense's own currency (budget_items.currency). Several payers can split who
|
* expense's own currency (budget_items.currency). Several payers can split who
|
||||||
@@ -112,10 +141,11 @@ export const budgetCreateItemRequestSchema = z.object({
|
|||||||
days: z.number().nullable().optional(),
|
days: z.number().nullable().optional(),
|
||||||
note: z.string().nullable().optional(),
|
note: z.string().nullable().optional(),
|
||||||
expense_date: z.string().nullable().optional(),
|
expense_date: z.string().nullable().optional(),
|
||||||
|
// Link this expense to a reservation (e.g. created from a booking's
|
||||||
|
// "add expense" flow). The server stores it on budget_items.reservation_id.
|
||||||
|
reservation_id: z.number().optional(),
|
||||||
});
|
});
|
||||||
export type BudgetCreateItemRequest = z.infer<
|
export type BudgetCreateItemRequest = z.infer<typeof budgetCreateItemRequestSchema>;
|
||||||
typeof budgetCreateItemRequestSchema
|
|
||||||
>;
|
|
||||||
|
|
||||||
/** Update accepts the same fields plus total_price changes; all optional. */
|
/** Update accepts the same fields plus total_price changes; all optional. */
|
||||||
export const budgetUpdateItemRequestSchema = z.object({
|
export const budgetUpdateItemRequestSchema = z.object({
|
||||||
@@ -131,17 +161,13 @@ export const budgetUpdateItemRequestSchema = z.object({
|
|||||||
note: z.string().nullable().optional(),
|
note: z.string().nullable().optional(),
|
||||||
expense_date: z.string().nullable().optional(),
|
expense_date: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
export type BudgetUpdateItemRequest = z.infer<
|
export type BudgetUpdateItemRequest = z.infer<typeof budgetUpdateItemRequestSchema>;
|
||||||
typeof budgetUpdateItemRequestSchema
|
|
||||||
>;
|
|
||||||
|
|
||||||
/** Replace the explicit payers of an expense (amounts in expense currency). */
|
/** Replace the explicit payers of an expense (amounts in expense currency). */
|
||||||
export const budgetUpdatePayersRequestSchema = z.object({
|
export const budgetUpdatePayersRequestSchema = z.object({
|
||||||
payers: z.array(payerInputSchema),
|
payers: z.array(payerInputSchema),
|
||||||
});
|
});
|
||||||
export type BudgetUpdatePayersRequest = z.infer<
|
export type BudgetUpdatePayersRequest = z.infer<typeof budgetUpdatePayersRequestSchema>;
|
||||||
typeof budgetUpdatePayersRequestSchema
|
|
||||||
>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A persisted settle-up transfer (budget_settlements row): "from paid to" a
|
* A persisted settle-up transfer (budget_settlements row): "from paid to" a
|
||||||
@@ -168,34 +194,32 @@ export const budgetCreateSettlementRequestSchema = z.object({
|
|||||||
to_user_id: z.number(),
|
to_user_id: z.number(),
|
||||||
amount: z.number(),
|
amount: z.number(),
|
||||||
});
|
});
|
||||||
export type BudgetCreateSettlementRequest = z.infer<
|
export type BudgetCreateSettlementRequest = z.infer<typeof budgetCreateSettlementRequestSchema>;
|
||||||
typeof budgetCreateSettlementRequestSchema
|
|
||||||
>;
|
/** Edit a persisted settle-up transfer (same fields as create; full replace). */
|
||||||
|
export const budgetUpdateSettlementRequestSchema = z.object({
|
||||||
|
from_user_id: z.number(),
|
||||||
|
to_user_id: z.number(),
|
||||||
|
amount: z.number(),
|
||||||
|
});
|
||||||
|
export type BudgetUpdateSettlementRequest = z.infer<typeof budgetUpdateSettlementRequestSchema>;
|
||||||
|
|
||||||
export const budgetUpdateMembersRequestSchema = z.object({
|
export const budgetUpdateMembersRequestSchema = z.object({
|
||||||
user_ids: z.array(z.number()),
|
user_ids: z.array(z.number()),
|
||||||
});
|
});
|
||||||
export type BudgetUpdateMembersRequest = z.infer<
|
export type BudgetUpdateMembersRequest = z.infer<typeof budgetUpdateMembersRequestSchema>;
|
||||||
typeof budgetUpdateMembersRequestSchema
|
|
||||||
>;
|
|
||||||
|
|
||||||
export const budgetToggleMemberPaidRequestSchema = z.object({
|
export const budgetToggleMemberPaidRequestSchema = z.object({
|
||||||
paid: z.boolean(),
|
paid: z.boolean(),
|
||||||
});
|
});
|
||||||
export type BudgetToggleMemberPaidRequest = z.infer<
|
export type BudgetToggleMemberPaidRequest = z.infer<typeof budgetToggleMemberPaidRequestSchema>;
|
||||||
typeof budgetToggleMemberPaidRequestSchema
|
|
||||||
>;
|
|
||||||
|
|
||||||
export const budgetReorderItemsRequestSchema = z.object({
|
export const budgetReorderItemsRequestSchema = z.object({
|
||||||
orderedIds: z.array(z.number()),
|
orderedIds: z.array(z.number()),
|
||||||
});
|
});
|
||||||
export type BudgetReorderItemsRequest = z.infer<
|
export type BudgetReorderItemsRequest = z.infer<typeof budgetReorderItemsRequestSchema>;
|
||||||
typeof budgetReorderItemsRequestSchema
|
|
||||||
>;
|
|
||||||
|
|
||||||
export const budgetReorderCategoriesRequestSchema = z.object({
|
export const budgetReorderCategoriesRequestSchema = z.object({
|
||||||
orderedCategories: z.array(z.string()),
|
orderedCategories: z.array(z.string()),
|
||||||
});
|
});
|
||||||
export type BudgetReorderCategoriesRequest = z.infer<
|
export type BudgetReorderCategoriesRequest = z.infer<typeof budgetReorderCategoriesRequestSchema>;
|
||||||
typeof budgetReorderCategoriesRequestSchema
|
|
||||||
>;
|
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import {
|
import { categorySchema, createCategoryRequestSchema, updateCategoryRequestSchema } from './category.schema';
|
||||||
categorySchema,
|
|
||||||
createCategoryRequestSchema,
|
|
||||||
updateCategoryRequestSchema,
|
|
||||||
} from './category.schema';
|
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
@@ -21,12 +17,8 @@ describe('categorySchema', () => {
|
|||||||
|
|
||||||
describe('createCategoryRequestSchema', () => {
|
describe('createCategoryRequestSchema', () => {
|
||||||
it('requires a non-empty name; colour and icon are optional', () => {
|
it('requires a non-empty name; colour and icon are optional', () => {
|
||||||
expect(
|
expect(createCategoryRequestSchema.safeParse({ name: 'Food' }).success).toBe(true);
|
||||||
createCategoryRequestSchema.safeParse({ name: 'Food' }).success,
|
expect(createCategoryRequestSchema.safeParse({ name: '' }).success).toBe(false);
|
||||||
).toBe(true);
|
|
||||||
expect(createCategoryRequestSchema.safeParse({ name: '' }).success).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
expect(createCategoryRequestSchema.safeParse({}).success).toBe(false);
|
expect(createCategoryRequestSchema.safeParse({}).success).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -34,8 +26,6 @@ describe('createCategoryRequestSchema', () => {
|
|||||||
describe('updateCategoryRequestSchema', () => {
|
describe('updateCategoryRequestSchema', () => {
|
||||||
it('allows every field to be omitted (the service COALESCEs)', () => {
|
it('allows every field to be omitted (the service COALESCEs)', () => {
|
||||||
expect(updateCategoryRequestSchema.safeParse({}).success).toBe(true);
|
expect(updateCategoryRequestSchema.safeParse({}).success).toBe(true);
|
||||||
expect(
|
expect(updateCategoryRequestSchema.safeParse({ color: '#000' }).success).toBe(true);
|
||||||
updateCategoryRequestSchema.safeParse({ color: '#000' }).success,
|
|
||||||
).toBe(true);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,12 +10,8 @@ import { describe, it, expect } from 'vitest';
|
|||||||
|
|
||||||
describe('collabNoteCreateRequestSchema', () => {
|
describe('collabNoteCreateRequestSchema', () => {
|
||||||
it('requires a non-empty title; the rest is optional', () => {
|
it('requires a non-empty title; the rest is optional', () => {
|
||||||
expect(
|
expect(collabNoteCreateRequestSchema.safeParse({ title: 'Idea' }).success).toBe(true);
|
||||||
collabNoteCreateRequestSchema.safeParse({ title: 'Idea' }).success,
|
expect(collabNoteCreateRequestSchema.safeParse({ title: '' }).success).toBe(false);
|
||||||
).toBe(true);
|
|
||||||
expect(collabNoteCreateRequestSchema.safeParse({ title: '' }).success).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
expect(collabNoteCreateRequestSchema.safeParse({}).success).toBe(false);
|
expect(collabNoteCreateRequestSchema.safeParse({}).success).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -34,50 +30,29 @@ describe('collabPollCreateRequestSchema', () => {
|
|||||||
options: ['A'],
|
options: ['A'],
|
||||||
}).success,
|
}).success,
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
expect(
|
expect(collabPollCreateRequestSchema.safeParse({ options: ['A', 'B'] }).success).toBe(false);
|
||||||
collabPollCreateRequestSchema.safeParse({ options: ['A', 'B'] }).success,
|
|
||||||
).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('collabPollVoteRequestSchema', () => {
|
describe('collabPollVoteRequestSchema', () => {
|
||||||
it('requires a numeric option_index', () => {
|
it('requires a numeric option_index', () => {
|
||||||
expect(
|
expect(collabPollVoteRequestSchema.safeParse({ option_index: 0 }).success).toBe(true);
|
||||||
collabPollVoteRequestSchema.safeParse({ option_index: 0 }).success,
|
expect(collabPollVoteRequestSchema.safeParse({ option_index: 'a' }).success).toBe(false);
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
collabPollVoteRequestSchema.safeParse({ option_index: 'a' }).success,
|
|
||||||
).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('collabMessageCreateRequestSchema', () => {
|
describe('collabMessageCreateRequestSchema', () => {
|
||||||
it('requires text, caps it at 5000, allows a nullable reply_to', () => {
|
it('requires text, caps it at 5000, allows a nullable reply_to', () => {
|
||||||
expect(
|
expect(collabMessageCreateRequestSchema.safeParse({ text: 'hi', reply_to: null }).success).toBe(true);
|
||||||
collabMessageCreateRequestSchema.safeParse({ text: 'hi', reply_to: null })
|
expect(collabMessageCreateRequestSchema.safeParse({ text: 'hi', reply_to: 4 }).success).toBe(true);
|
||||||
.success,
|
expect(collabMessageCreateRequestSchema.safeParse({ text: '' }).success).toBe(false);
|
||||||
).toBe(true);
|
expect(collabMessageCreateRequestSchema.safeParse({ text: 'x'.repeat(5001) }).success).toBe(false);
|
||||||
expect(
|
|
||||||
collabMessageCreateRequestSchema.safeParse({ text: 'hi', reply_to: 4 })
|
|
||||||
.success,
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
collabMessageCreateRequestSchema.safeParse({ text: '' }).success,
|
|
||||||
).toBe(false);
|
|
||||||
expect(
|
|
||||||
collabMessageCreateRequestSchema.safeParse({ text: 'x'.repeat(5001) })
|
|
||||||
.success,
|
|
||||||
).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('collabReactionRequestSchema', () => {
|
describe('collabReactionRequestSchema', () => {
|
||||||
it('requires a non-empty emoji', () => {
|
it('requires a non-empty emoji', () => {
|
||||||
expect(collabReactionRequestSchema.safeParse({ emoji: '👍' }).success).toBe(
|
expect(collabReactionRequestSchema.safeParse({ emoji: '👍' }).success).toBe(true);
|
||||||
true,
|
expect(collabReactionRequestSchema.safeParse({ emoji: '' }).success).toBe(false);
|
||||||
);
|
|
||||||
expect(collabReactionRequestSchema.safeParse({ emoji: '' }).success).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,9 +18,7 @@ export const collabNoteCreateRequestSchema = z.object({
|
|||||||
color: z.string().optional(),
|
color: z.string().optional(),
|
||||||
website: z.string().optional(),
|
website: z.string().optional(),
|
||||||
});
|
});
|
||||||
export type CollabNoteCreateRequest = z.infer<
|
export type CollabNoteCreateRequest = z.infer<typeof collabNoteCreateRequestSchema>;
|
||||||
typeof collabNoteCreateRequestSchema
|
|
||||||
>;
|
|
||||||
|
|
||||||
export const collabNoteUpdateRequestSchema = z.object({
|
export const collabNoteUpdateRequestSchema = z.object({
|
||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
@@ -30,9 +28,7 @@ export const collabNoteUpdateRequestSchema = z.object({
|
|||||||
pinned: z.union([z.boolean(), z.number()]).optional(),
|
pinned: z.union([z.boolean(), z.number()]).optional(),
|
||||||
website: z.string().optional(),
|
website: z.string().optional(),
|
||||||
});
|
});
|
||||||
export type CollabNoteUpdateRequest = z.infer<
|
export type CollabNoteUpdateRequest = z.infer<typeof collabNoteUpdateRequestSchema>;
|
||||||
typeof collabNoteUpdateRequestSchema
|
|
||||||
>;
|
|
||||||
|
|
||||||
export const collabPollCreateRequestSchema = z.object({
|
export const collabPollCreateRequestSchema = z.object({
|
||||||
question: z.string().min(1),
|
question: z.string().min(1),
|
||||||
@@ -41,9 +37,7 @@ export const collabPollCreateRequestSchema = z.object({
|
|||||||
multiple_choice: z.boolean().optional(),
|
multiple_choice: z.boolean().optional(),
|
||||||
deadline: z.string().optional(),
|
deadline: z.string().optional(),
|
||||||
});
|
});
|
||||||
export type CollabPollCreateRequest = z.infer<
|
export type CollabPollCreateRequest = z.infer<typeof collabPollCreateRequestSchema>;
|
||||||
typeof collabPollCreateRequestSchema
|
|
||||||
>;
|
|
||||||
|
|
||||||
export const collabPollVoteRequestSchema = z.object({
|
export const collabPollVoteRequestSchema = z.object({
|
||||||
option_index: z.number(),
|
option_index: z.number(),
|
||||||
@@ -54,9 +48,7 @@ export const collabMessageCreateRequestSchema = z.object({
|
|||||||
text: z.string().min(1).max(5000),
|
text: z.string().min(1).max(5000),
|
||||||
reply_to: z.number().nullable().optional(),
|
reply_to: z.number().nullable().optional(),
|
||||||
});
|
});
|
||||||
export type CollabMessageCreateRequest = z.infer<
|
export type CollabMessageCreateRequest = z.infer<typeof collabMessageCreateRequestSchema>;
|
||||||
typeof collabMessageCreateRequestSchema
|
|
||||||
>;
|
|
||||||
|
|
||||||
export const collabReactionRequestSchema = z.object({
|
export const collabReactionRequestSchema = z.object({
|
||||||
emoji: z.string().min(1),
|
emoji: z.string().min(1),
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user