mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Compare commits
2 Commits
c15c89ca61
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 438d4fc400 | |||
| d152f9d02b |
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 3.1.0
|
||||
version: 3.1.1
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "3.1.0"
|
||||
appVersion: "3.1.1"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "3.1.0",
|
||||
"version": "3.1.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -489,7 +489,7 @@ export const addonsApi = {
|
||||
|
||||
export const airtrailApi = {
|
||||
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),
|
||||
status: () => apiClient.get('/integrations/airtrail/status').then(r => r.data),
|
||||
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),
|
||||
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),
|
||||
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),
|
||||
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),
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useToast } from '../shared/Toast'
|
||||
import Section from '../Settings/Section'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { MapView } from '../Map/MapView'
|
||||
import { CURRENCIES, SYMBOLS } from '../Budget/BudgetPanel.constants'
|
||||
import type { Place } from '../../types'
|
||||
|
||||
const MAP_PRESETS = [
|
||||
@@ -20,6 +21,7 @@ type Defaults = {
|
||||
temperature_unit?: string
|
||||
dark_mode?: string | boolean
|
||||
time_format?: string
|
||||
default_currency?: string
|
||||
blur_booking_codes?: boolean
|
||||
map_tile_url?: string
|
||||
map_provider?: string
|
||||
@@ -226,6 +228,23 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
))}
|
||||
</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 */}
|
||||
<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 { 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 { useAuthStore } from '../../store/authStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -39,6 +39,12 @@ interface SettlementData {
|
||||
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 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 [filter, setFilter] = useState<'all' | 'mine' | 'owed'>('all')
|
||||
const [search, setSearch] = useState('')
|
||||
const [histOpen, setHistOpen] = useState(false)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<BudgetItem | null>(null)
|
||||
const [editingSettlement, setEditingSettlement] = useState<Settlement | null>(null)
|
||||
const [addingPayment, setAddingPayment] = useState(false)
|
||||
|
||||
const people = tripMembers
|
||||
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
|
||||
}, [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 groups: { day: string; items: BudgetItem[] }[] = []
|
||||
const labelOf = (e: BudgetItem) => {
|
||||
if (!e.expense_date) return t('costs.noDate')
|
||||
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 entries: LedgerEntry[] = [
|
||||
...filtered.map(e => ({ kind: 'expense' as const, date: e.expense_date || '', e })),
|
||||
...filteredSettlements.map(s => ({ kind: 'payment' as const, date: (s.created_at || '').slice(0, 10), s })),
|
||||
]
|
||||
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 || ''))
|
||||
for (const e of sorted) {
|
||||
const day = labelOf(e)
|
||||
// Newest day first; within a day, expenses before payments (insertion order).
|
||||
const sorted = entries.slice().sort((a, b) => (b.date || '').localeCompare(a.date || ''))
|
||||
const groups: { day: string; entries: LedgerEntry[] }[] = []
|
||||
for (const en of sorted) {
|
||||
const day = labelOf(en.date)
|
||||
let g = groups.find(x => x.day === day)
|
||||
if (!g) { g = { day, items: [] }; groups.push(g) }
|
||||
g.items.push(e)
|
||||
if (!g) { g = { day, entries: [] }; groups.push(g) }
|
||||
g.entries.push(en)
|
||||
}
|
||||
return groups
|
||||
}, [filtered, locale, t])
|
||||
}, [filtered, filteredSettlements, locale, t])
|
||||
|
||||
// ── settle actions ──────────────────────────────────────────────────────
|
||||
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')}
|
||||
</div>
|
||||
) : 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 (
|
||||
<div key={g.day} style={{ marginBottom: 22 }}>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
)
|
||||
@@ -300,11 +325,13 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
|
||||
<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>
|
||||
<button disabled={!(settlement?.settlements || []).length} onClick={() => setHistOpen(true)}
|
||||
className="text-content-muted bg-surface-secondary border border-edge disabled:opacity-40"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 9px', borderRadius: 8, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<History size={13} /> {t('costs.history')}{(settlement?.settlements || []).length ? ` (${settlement!.settlements.length})` : ''}
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button onClick={() => setAddingPayment(true)}
|
||||
className="text-content-muted bg-surface-secondary border border-edge"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 9px', borderRadius: 8, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<Plus size={13} /> {t('costs.addPayment')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<SettleFlows />
|
||||
</div>
|
||||
@@ -330,9 +357,11 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
onSaved={() => { setModalOpen(false); loadBudgetItems(tripId); loadSettlement() }} />
|
||||
)}
|
||||
|
||||
<Modal isOpen={histOpen} onClose={() => setHistOpen(false)} title={t('costs.settleHistory')} size="md">
|
||||
<SettleHistory settlements={settlement?.settlements || []} fmt={fmt} Avatar={Avatar} name={personName} onUndo={undoSettlement} canEdit={canEdit} />
|
||||
</Modal>
|
||||
{(editingSettlement || addingPayment) && (
|
||||
<SettlementModal tripId={tripId} people={people} me={me} editing={editingSettlement}
|
||||
onClose={() => { setEditingSettlement(null); setAddingPayment(false) }}
|
||||
onSaved={() => { setEditingSettlement(null); setAddingPayment(false); loadSettlement() }} />
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.costs-root {
|
||||
@@ -438,7 +467,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
|
||||
<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>
|
||||
<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>
|
||||
<SettleFlows />
|
||||
</div>
|
||||
@@ -458,11 +489,13 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
{dayGroups.length === 0
|
||||
? <div className="text-content-faint" style={{ textAlign: 'center', padding: '36px 16px', fontSize: 13 }}>{search ? t('costs.noMatch') : t('costs.emptyText')}</div>
|
||||
: 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 (
|
||||
<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 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>
|
||||
)
|
||||
})}
|
||||
@@ -490,11 +523,22 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const cur = curOf(e)
|
||||
const payers = (e.payers || []).filter(p => p.amount > 0)
|
||||
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 (
|
||||
<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>
|
||||
<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 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginBottom: 5 }}>
|
||||
{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={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<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' }}>
|
||||
{net > 0 ? t('costs.youLent', { amount: fmt(net) }) : t('costs.youBorrowed', { amount: fmt(-net) })}
|
||||
</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'] }) {
|
||||
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)))
|
||||
@@ -633,31 +703,62 @@ function FlowPills({ ids, lead, Avatar, name }: { ids: number[]; lead: string; A
|
||||
)
|
||||
}
|
||||
|
||||
function SettleHistory({ settlements, fmt, Avatar, name, onUndo, canEdit }: {
|
||||
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
|
||||
// Add or edit a settle-up payment (from / to / amount). Reachable inline from the
|
||||
// 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()
|
||||
if (settlements.length === 0) return <div className="text-content-faint" style={{ textAlign: 'center', padding: 30, fontSize: 13 }}>{t('costs.noSettlements')}</div>
|
||||
const total = settlements.reduce((a, s) => a + s.amount, 0)
|
||||
const toast = useToast()
|
||||
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 (
|
||||
<div>
|
||||
<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 }}>
|
||||
<span>{t('costs.paymentsSettled', { count: settlements.length })}</span><span>{fmt(total)}</span>
|
||||
<Modal isOpen onClose={onClose} title={editing ? t('costs.editPayment') : t('costs.addPayment')} size="md"
|
||||
footer={
|
||||
<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 style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{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>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -682,43 +783,88 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : (prefill?.category || 'food'))
|
||||
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
|
||||
const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10))
|
||||
const [payers, setPayers] = useState<Record<number, string>>(() => {
|
||||
const m: Record<number, string> = {}
|
||||
for (const p of editing?.payers || []) m[p.user_id] = String(p.amount)
|
||||
return m
|
||||
})
|
||||
// Standalone total for "recorded amount, nobody has paid yet" expenses (created
|
||||
// from a booking, or pre-rework items). Used only while no per-person amount is
|
||||
// entered; once a payer has an amount, the total derives from the payers.
|
||||
const [amount, setAmount] = useState<string>(() => {
|
||||
if (editing && !(editing.payers && editing.payers.length > 0)) return editing.total_price ? String(editing.total_price) : ''
|
||||
// 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 [split, setSplit] = useState<Set<number>>(() =>
|
||||
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> = {}
|
||||
for (const p of editing?.payers || []) if (p.amount > 0) m[p.user_id] = String(p.amount)
|
||||
return m
|
||||
})
|
||||
// Amounts the user pinned by typing — kept out of the auto-rebalance. Existing
|
||||
// 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 payersTotal = Object.values(payers).reduce((a, v) => a + (parseFloat(v) || 0), 0)
|
||||
const hasPayers = payersTotal > 0
|
||||
const total = hasPayers ? payersTotal : (parseFloat(amount) || 0)
|
||||
const each = split.size > 0 ? total / split.size : 0
|
||||
const valid = name.trim().length > 0 && total > 0 && (hasPayers ? split.size > 0 : true)
|
||||
const totalNum = parseFloat(total) || 0
|
||||
const paidSum = round2([...participants].reduce((a, id) => a + (parseFloat(paid[id]) || 0), 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 () => {
|
||||
if (!valid) return
|
||||
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 = {
|
||||
name: name.trim(), category: cat,
|
||||
// Store the actual currency the amounts were entered in; conversion to the
|
||||
// viewer's display currency happens live (real rates), no manual rate.
|
||||
currency,
|
||||
payers: payerList, member_ids: [...split],
|
||||
payers: payerList, member_ids: [...participants],
|
||||
expense_date: day || null,
|
||||
// No per-person amounts: record the typed total directly (the server keeps
|
||||
// it instead of deriving 0 from the empty payer list).
|
||||
...(payerList.length === 0 ? { total_price: parseFloat(amount) || 0 } : {}),
|
||||
// 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 } : {}),
|
||||
}
|
||||
@@ -750,13 +896,9 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
<label className={labelCls}>{t('costs.totalAmount')}</label>
|
||||
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px' }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 15 }}>{sym(currency)}</span>
|
||||
{hasPayers ? (
|
||||
<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={amount}
|
||||
onChange={e => setAmount(e.target.value)}
|
||||
className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 15, fontWeight: 600, paddingLeft: 6, width: '100%' }} />
|
||||
)}
|
||||
<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 style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||
@@ -772,11 +914,11 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currency !== base && total > 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' }}>
|
||||
<span>{formatMoney(total, currency, locale)}</span>
|
||||
<span>{formatMoney(totalNum, currency, locale)}</span>
|
||||
<span className="text-content-faint">≈</span>
|
||||
<span className="text-content" style={{ fontWeight: 600 }}>{formatMoney(convert(total, 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>
|
||||
</div>
|
||||
)}
|
||||
@@ -801,39 +943,37 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
<div>
|
||||
<label className={labelCls}>{t('costs.whoPaid')}</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
{people.map(p => (
|
||||
<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 }}>
|
||||
<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)
|
||||
{people.map((p, idx) => {
|
||||
const on = participants.has(p.id)
|
||||
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 })}
|
||||
className={on ? 'bg-surface-card text-content border' : 'bg-surface-secondary text-content-faint border border-edge'}
|
||||
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
|
||||
? <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[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>}
|
||||
{p.id === me ? t('costs.you') : p.username}
|
||||
</button>
|
||||
<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 }}>
|
||||
<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' }}>
|
||||
{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 }} />
|
||||
: <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 className="text-content" style={{ fontSize: 14, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.id === me ? t('costs.you') : p.username}</span>
|
||||
</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 className="text-content-faint" style={{ marginTop: 10, fontSize: 12.5 }}>
|
||||
{split.size === 0 ? t('costs.pickSomeone') : t('costs.splitSummary', { count: split.size, amount: sym(currency) + each.toFixed(2) })}
|
||||
<div style={{ marginTop: 10, fontSize: 12.5, display: 'flex', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
|
||||
<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>
|
||||
|
||||
@@ -399,17 +399,38 @@ describe('PlaceFormModal', () => {
|
||||
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' });
|
||||
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);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-026c: hydrates Start/End from the assignment when the pool place lacks times (#1247)', () => {
|
||||
// The pool Place carries no times — they live on the day-assignment. Opening the
|
||||
// editor with an assignmentId must hydrate the fields from assignment.place, not
|
||||
// the (timeless) pool place that the Places panel passes in.
|
||||
const poolPlace = buildPlace({ id: 7, name: 'Museum' });
|
||||
const assignmentPlace = buildPlace({ id: 7, name: 'Museum', place_time: '20:20', end_time: '20:34' });
|
||||
const assignment = buildAssignment({ id: 42, day_id: 3, place: assignmentPlace });
|
||||
render(<PlaceFormModal {...defaultProps} place={poolPlace} assignmentId={42} dayAssignments={[assignment]} />);
|
||||
expect(screen.getByDisplayValue('20:20')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('20:34')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-027: end-before-start error disables submit', () => {
|
||||
// Build 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' });
|
||||
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
|
||||
const submitBtn = screen.getByRole('button', { name: /^Update$/i });
|
||||
|
||||
@@ -92,6 +92,11 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (place) {
|
||||
// Times are stored per day-assignment, not on the pool place. When an
|
||||
// assignment is in context (itinerary edit, or a single-assignment pool
|
||||
// edit) read the times off its embedded place; fall back to the place prop.
|
||||
const assignment = assignmentId ? dayAssignments.find(a => a.id === assignmentId) : null
|
||||
const timeSource = assignment?.place ?? place
|
||||
setForm({
|
||||
name: place.name || '',
|
||||
description: place.description || '',
|
||||
@@ -99,8 +104,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
lat: place.lat != null ? String(place.lat) : '',
|
||||
lng: place.lng != null ? String(place.lng) : '',
|
||||
category_id: place.category_id != null ? String(place.category_id) : '',
|
||||
place_time: place.place_time || '',
|
||||
end_time: place.end_time || '',
|
||||
place_time: timeSource.place_time || '',
|
||||
end_time: timeSource.end_time || '',
|
||||
notes: place.notes || '',
|
||||
transport_mode: place.transport_mode || 'walking',
|
||||
website: place.website || '',
|
||||
@@ -121,7 +126,10 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
}
|
||||
setPendingFiles([])
|
||||
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
|
||||
const places = useTripStore((s) => s.places)
|
||||
@@ -728,8 +736,11 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Time — only shown when editing, not when creating */}
|
||||
{place && (
|
||||
{/* Time is per day-assignment: only shown when a single assignment is in
|
||||
context (itinerary edit, or a single-assignment pool edit). Hidden when
|
||||
creating, and for unassigned / multi-day pool edits where a single time
|
||||
is ambiguous and wouldn't persist. */}
|
||||
{place && assignmentId && (
|
||||
<TimeSection
|
||||
form={form}
|
||||
handleChange={handleChange}
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function AirTrailConnectionSection(): React.ReactElement {
|
||||
const [url, setUrl] = useState('')
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [allowInsecureTls, setAllowInsecureTls] = useState(false)
|
||||
const [writeEnabled, setWriteEnabled] = useState(false)
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
@@ -30,6 +31,7 @@ export default function AirTrailConnectionSection(): React.ReactElement {
|
||||
.then(d => {
|
||||
setUrl(d.url || '')
|
||||
setAllowInsecureTls(!!d.allowInsecureTls)
|
||||
setWriteEnabled(!!d.writeEnabled)
|
||||
setConnected(!!d.connected)
|
||||
})
|
||||
.catch(() => {})
|
||||
@@ -46,7 +48,7 @@ export default function AirTrailConnectionSection(): React.ReactElement {
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
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 }))
|
||||
setConnected(!!status.connected)
|
||||
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>
|
||||
</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">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
|
||||
@@ -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 type { TranslationFn } from '../types'
|
||||
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 AtlasCountrySearch from './atlas/AtlasCountrySearch'
|
||||
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`)
|
||||
setData(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) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
@@ -260,7 +262,8 @@ export default function AtlasPage(): React.ReactElement {
|
||||
})
|
||||
setData(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) {
|
||||
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
|
||||
const remainingRegions = (visitedRegions[countryCode] || []).filter(r => r.code !== rCode && r.manuallyMarked)
|
||||
if (remainingRegions.length > 0) return prev
|
||||
const cont = continentForCountry(countryCode)
|
||||
return {
|
||||
...prev,
|
||||
countries: prev.countries.filter(c => c.code !== countryCode),
|
||||
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) {
|
||||
|
||||
@@ -38,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 {
|
||||
if (!dateStr) return null
|
||||
const date = new Date(dateStr + 'T00:00:00Z')
|
||||
if (isNaN(date.getTime())) return null // malformed date — render a dash, never crash
|
||||
return {
|
||||
d: date.toLocaleDateString(locale, { day: 'numeric', timeZone: 'UTC' }),
|
||||
m: date.toLocaleDateString(locale, { month: 'short', timeZone: 'UTC' }),
|
||||
|
||||
@@ -1160,10 +1160,13 @@ describe('TripPlannerPage', () => {
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
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);
|
||||
|
||||
@@ -1179,20 +1182,24 @@ describe('TripPlannerPage', () => {
|
||||
expect(screen.getByTestId('reservations-panel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Set editingReservation via captured onEdit prop (inline lambda in JSX)
|
||||
const fakeReservation = { id: 1, trip_id: 42, name: 'Test', type: 'restaurant', status: 'confirmed' };
|
||||
// Edit a reservation that lives on day 7 (no day is selected — Book tab).
|
||||
const fakeReservation = { id: 1, trip_id: 42, name: 'Test', type: 'other', status: 'confirmed', day_id: 7 };
|
||||
await act(async () => {
|
||||
capturedReservationsPanelProps.current.onEdit?.(fakeReservation);
|
||||
});
|
||||
|
||||
// Call onSave — now takes edit path (editingReservation is set)
|
||||
await act(async () => {
|
||||
await capturedReservationModalProps.current.onSave?.({
|
||||
name: 'Updated Booking',
|
||||
type: 'restaurant',
|
||||
type: 'tour',
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -203,7 +203,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
expandedDayIds, setExpandedDayIds, mapPlaces,
|
||||
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
|
||||
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
|
||||
handleSavePlace, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
|
||||
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
|
||||
handleAssignToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
|
||||
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
|
||||
selectedPlace, dayOrderMap, dayPlaces,
|
||||
@@ -465,7 +465,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
onPlaceClick={handlePlaceClick}
|
||||
onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true) }}
|
||||
onAssignToDay={handleAssignToDay}
|
||||
onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true) }}
|
||||
onEditPlace={(place) => openPlaceEditor(place)}
|
||||
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
|
||||
onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)}
|
||||
onCategoryFilterChange={setMapCategoryFilter}
|
||||
@@ -531,17 +531,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
assignments={assignments}
|
||||
reservations={reservations}
|
||||
onClose={() => setSelectedPlaceId(null)}
|
||||
onEdit={() => {
|
||||
if (selectedAssignmentId) {
|
||||
const assignmentObj = Object.values(assignments).flat().find(a => a.id === selectedAssignmentId)
|
||||
const placeWithAssignmentTimes = assignmentObj?.place ? { ...selectedPlace, place_time: assignmentObj.place.place_time, end_time: assignmentObj.place.end_time } : selectedPlace
|
||||
setEditingPlace(placeWithAssignmentTimes)
|
||||
} else {
|
||||
setEditingPlace(selectedPlace)
|
||||
}
|
||||
setEditingAssignmentId(selectedAssignmentId || null)
|
||||
setShowPlaceForm(true)
|
||||
}}
|
||||
onEdit={() => openPlaceEditor(selectedPlace, selectedAssignmentId)}
|
||||
onDelete={() => handleDeletePlace(selectedPlace.id)}
|
||||
onAssignToDay={handleAssignToDay}
|
||||
onRemoveAssignment={handleRemoveAssignment}
|
||||
@@ -579,18 +569,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
assignments={assignments}
|
||||
reservations={reservations}
|
||||
onClose={() => setSelectedPlaceId(null)}
|
||||
onEdit={() => {
|
||||
if (selectedAssignmentId) {
|
||||
const assignmentObj = Object.values(assignments).flat().find(a => a.id === selectedAssignmentId)
|
||||
const placeWithAssignmentTimes = assignmentObj?.place ? { ...selectedPlace, place_time: assignmentObj.place.place_time, end_time: assignmentObj.place.end_time } : selectedPlace
|
||||
setEditingPlace(placeWithAssignmentTimes)
|
||||
} else {
|
||||
setEditingPlace(selectedPlace)
|
||||
}
|
||||
setEditingAssignmentId(selectedAssignmentId || null)
|
||||
setShowPlaceForm(true)
|
||||
setSelectedPlaceId(null)
|
||||
}}
|
||||
onEdit={() => { openPlaceEditor(selectedPlace, selectedAssignmentId); setSelectedPlaceId(null) }}
|
||||
onDelete={() => { handleDeletePlace(selectedPlace.id); setSelectedPlaceId(null) }}
|
||||
onAssignToDay={handleAssignToDay}
|
||||
onRemoveAssignment={handleRemoveAssignment}
|
||||
@@ -631,7 +610,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{mobileSidebarOpen === 'left'
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded />
|
||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { 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>
|
||||
@@ -717,7 +696,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={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} />
|
||||
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
|
||||
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} onOpenExpense={openBookingExpense} />
|
||||
|
||||
@@ -6,6 +6,7 @@ import apiClient, { mapsApi } from '../../api/client'
|
||||
import L from 'leaflet'
|
||||
import type { GeoJsonFeatureCollection } from '../../types'
|
||||
import { A2_TO_A3, type AtlasData, type CountryDetail, type BucketItem } from './atlasModel'
|
||||
import { continentForCountry } from '@trek/shared'
|
||||
|
||||
function useCountryNames(language: string): (code: string) => string {
|
||||
const [resolver, setResolver] = useState<(code: string) => string>(() => (code: string) => code)
|
||||
@@ -582,10 +583,12 @@ export function useAtlas() {
|
||||
apiClient.post(`/addons/atlas/country/${code}/mark`).catch(() => {})
|
||||
setData(prev => {
|
||||
if (!prev || prev.countries.find(c => c.code === code)) return prev
|
||||
const cont = continentForCountry(code)
|
||||
return {
|
||||
...prev,
|
||||
countries: [...prev.countries, { 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 },
|
||||
}
|
||||
})
|
||||
} else {
|
||||
@@ -596,10 +599,12 @@ export function useAtlas() {
|
||||
if (!prev) return prev
|
||||
const c = prev.countries.find(c => c.code === code)
|
||||
if (!c || c.placeCount > 0 || c.tripCount > 0) return prev
|
||||
const cont = continentForCountry(code)
|
||||
return {
|
||||
...prev,
|
||||
countries: prev.countries.filter(c => c.code !== code),
|
||||
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 => {
|
||||
|
||||
@@ -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 { useAirtrailConnection } from '../../hooks/useAirtrailConnection'
|
||||
import type { Accommodation, TripMember, Day, Place, Reservation } from '../../types'
|
||||
import { resolvePoolAssignmentId } from './tripPlannerModel'
|
||||
|
||||
/**
|
||||
* Trip planner page logic — the big one. Owns the trip store wiring, addon
|
||||
@@ -423,6 +424,16 @@ export function useTripPlanner() {
|
||||
}
|
||||
}, [editingPlace, editingAssignmentId, tripId, toast, pushUndo])
|
||||
|
||||
// Open the place editor from any entry point (Places pool, inspector, map).
|
||||
// Times live per day-assignment, so when no day is in context resolve the
|
||||
// place's lone assignment to hydrate & persist its times; with 0 or 2+
|
||||
// assignments the time is ambiguous and the modal hides the fields (#1247).
|
||||
const openPlaceEditor = useCallback((place: Place, preferredAssignmentId: number | null = null) => {
|
||||
setEditingPlace(place)
|
||||
setEditingAssignmentId(preferredAssignmentId ?? resolvePoolAssignmentId(assignments, place.id))
|
||||
setShowPlaceForm(true)
|
||||
}, [assignments])
|
||||
|
||||
const handleDeletePlace = useCallback((placeId) => {
|
||||
setDeletePlaceId(placeId)
|
||||
}, [])
|
||||
@@ -568,7 +579,12 @@ export function useTripPlanner() {
|
||||
const handleSaveReservation = async (data: Record<string, string | number | null> & { title: string }) => {
|
||||
try {
|
||||
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'))
|
||||
setShowReservationModal(false)
|
||||
setEditingReservation(null)
|
||||
@@ -685,7 +701,7 @@ export function useTripPlanner() {
|
||||
expandedDayIds, setExpandedDayIds, mapPlaces,
|
||||
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
|
||||
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
|
||||
handleSavePlace, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
|
||||
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
|
||||
handleAssignToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
|
||||
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
|
||||
selectedPlace, dayOrderMap, dayPlaces,
|
||||
|
||||
Generated
+1145
-1415
File diff suppressed because it is too large
Load Diff
+3
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@trek/root",
|
||||
"private": true,
|
||||
"version": "3.1.0",
|
||||
"version": "3.1.1",
|
||||
"workspaces": [
|
||||
"client",
|
||||
"server",
|
||||
@@ -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.",
|
||||
"overrides": {
|
||||
"react": "19.2.6",
|
||||
"react-dom": "19.2.6"
|
||||
"react-dom": "19.2.6",
|
||||
"multer": "^2.2.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-linux-x64-musl": "4.62.0",
|
||||
|
||||
Binary file not shown.
+4
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/server",
|
||||
"version": "3.1.0",
|
||||
"version": "3.1.1",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"start": "node --require tsconfig-paths/register dist/index.js",
|
||||
@@ -38,9 +38,8 @@
|
||||
"helmet": "^8.1.0",
|
||||
"jimp": "^1.6.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.1.1",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^8.0.5",
|
||||
"nodemailer": "^9.0.1",
|
||||
"otplib": "^12.0.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
@@ -60,7 +59,7 @@
|
||||
"@hono/node-server": "^1.19.13",
|
||||
"picomatch": "^4.0.4",
|
||||
"ip-address": "^10.1.1",
|
||||
"multer": "^2.1.1",
|
||||
"multer": "^2.2.0",
|
||||
"ws": "^8.21.0",
|
||||
"qs": "^6.15.2",
|
||||
"file-type": "^21.3.4"
|
||||
@@ -80,7 +79,7 @@
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/supertest": "^6.0.3",
|
||||
|
||||
@@ -151,18 +151,37 @@ function normalizeAdm0Feature(f) {
|
||||
|
||||
function normalizeAdm1(geo, a3, countryName) {
|
||||
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 => {
|
||||
const name = f.properties?.shapeName || ''
|
||||
const geometry = quantizeGeometry(f.geometry, ADM1_DECIMALS)
|
||||
if (!geometry) return null
|
||||
const a2 = A3_TO_A2[a3] || null
|
||||
// shapeISO is a real ISO 3166-2 code for ~90% of features; geoBoundaries leaves the
|
||||
// rest blank or uses an `XX_YYY` placeholder. Keep real/placeholder codes as-is
|
||||
// (stable per polygon → manual mark/unmark works, real ones match Nominatim). For
|
||||
// blank codes, synthesize a stable id mirroring the server's geocode fallback so
|
||||
// every region is still markable.
|
||||
let code = f.properties?.shapeISO || ''
|
||||
if (!code && a2) code = `${a2}-${name.replace(/[^A-Za-z0-9]/g, '').substring(0, 3).toUpperCase()}`
|
||||
// shapeISO is a real ISO 3166-2 code for most features, but geoBoundaries sometimes
|
||||
// fills it with the bare country code instead of a subdivision code — e.g. every
|
||||
// Spanish region gets "ESP", every Chinese "CHN" (also CL/OM). Keep it only when it
|
||||
// is a real `XX-…` subdivision code and not already taken; otherwise synthesize a
|
||||
// stable, unique-per-country id from the region name so each region is independently
|
||||
// markable.
|
||||
const raw = f.properties?.shapeISO || ''
|
||||
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 {
|
||||
type: 'Feature',
|
||||
// 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 fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { SUPPORTED_LANGUAGE_CODES as SUPPORTED_LANG_CODES } from '@trek/shared';
|
||||
|
||||
const dataDir = path.resolve(__dirname, '../data');
|
||||
|
||||
// JWT_SECRET is always managed by the server — auto-generated on first start and
|
||||
// persisted to data/.jwt_secret. Use the admin panel to rotate it; do not set it
|
||||
// via environment variable (env var would override a rotation on next restart).
|
||||
const jwtSecretFile = path.join(dataDir, '.jwt_secret');
|
||||
let _jwtSecret: string;
|
||||
|
||||
try {
|
||||
_jwtSecret = fs.readFileSync(jwtSecretFile, 'utf8').trim();
|
||||
} catch {
|
||||
_jwtSecret = crypto.randomBytes(32).toString('hex');
|
||||
try {
|
||||
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
||||
fs.writeFileSync(jwtSecretFile, _jwtSecret, { mode: 0o600 });
|
||||
console.log('Generated and saved JWT secret to', jwtSecretFile);
|
||||
} catch (writeErr: unknown) {
|
||||
console.warn('WARNING: Could not persist JWT secret to disk:', writeErr instanceof Error ? writeErr.message : writeErr);
|
||||
console.warn('Sessions will reset on server restart.');
|
||||
}
|
||||
}
|
||||
|
||||
// export let so TypeScript's CJS output keeps exports.JWT_SECRET live
|
||||
// (generates `exports.JWT_SECRET = JWT_SECRET = newVal` inside updateJwtSecret)
|
||||
export let JWT_SECRET = _jwtSecret;
|
||||
|
||||
// Called by the admin rotate-jwt-secret endpoint to update the in-process
|
||||
// binding that all middleware and route files reference.
|
||||
export function updateJwtSecret(newSecret: string): void {
|
||||
JWT_SECRET = newSecret;
|
||||
}
|
||||
|
||||
// ENCRYPTION_KEY is used to derive at-rest encryption keys for stored secrets
|
||||
// (API keys, MFA TOTP secrets, SMTP password, OIDC client secret, etc.).
|
||||
@@ -93,18 +65,55 @@ if (_encryptionKey) {
|
||||
fs.writeFileSync(encKeyFile, _encryptionKey, { mode: 0o600 });
|
||||
console.log('Encryption key persisted to', encKeyFile);
|
||||
} catch (writeErr: unknown) {
|
||||
console.warn('WARNING: Could not persist encryption key to disk:', writeErr instanceof Error ? writeErr.message : writeErr);
|
||||
console.warn(
|
||||
'WARNING: Could not persist encryption key to disk:',
|
||||
writeErr instanceof Error ? writeErr.message : writeErr,
|
||||
);
|
||||
console.warn('Set ENCRYPTION_KEY env var to avoid losing access to encrypted secrets on restart.');
|
||||
}
|
||||
}
|
||||
|
||||
export const ENCRYPTION_KEY = _encryptionKey;
|
||||
|
||||
// JWT_SECRET is always managed by the server — auto-generated on first start and
|
||||
// persisted to data/.jwt_secret. Use the admin panel to rotate it; do not set it
|
||||
// via environment variable (env var would override a rotation on next restart).
|
||||
let _jwtSecret: string;
|
||||
|
||||
try {
|
||||
_jwtSecret = fs.readFileSync(jwtSecretFile, 'utf8').trim();
|
||||
} catch {
|
||||
_jwtSecret = crypto.randomBytes(32).toString('hex');
|
||||
try {
|
||||
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
||||
fs.writeFileSync(jwtSecretFile, _jwtSecret, { mode: 0o600 });
|
||||
console.log('Generated and saved JWT secret to', jwtSecretFile);
|
||||
} catch (writeErr: unknown) {
|
||||
console.warn(
|
||||
'WARNING: Could not persist JWT secret to disk:',
|
||||
writeErr instanceof Error ? writeErr.message : writeErr,
|
||||
);
|
||||
console.warn('Sessions will reset on server restart.');
|
||||
}
|
||||
}
|
||||
|
||||
// export let so TypeScript's CJS output keeps exports.JWT_SECRET live
|
||||
// (generates `exports.JWT_SECRET = JWT_SECRET = newVal` inside updateJwtSecret)
|
||||
export let JWT_SECRET = _jwtSecret;
|
||||
|
||||
// Called by the admin rotate-jwt-secret endpoint to update the in-process
|
||||
// binding that all middleware and route files reference.
|
||||
export function updateJwtSecret(newSecret: string): void {
|
||||
JWT_SECRET = newSecret;
|
||||
}
|
||||
|
||||
// DEFAULT_LANGUAGE sets the language shown on the login page before the user
|
||||
// selects one. Only applies when the user has no saved language preference.
|
||||
const rawDefaultLang = process.env.DEFAULT_LANGUAGE?.toLowerCase() || 'en';
|
||||
if (!SUPPORTED_LANG_CODES.includes(rawDefaultLang)) {
|
||||
console.warn(`DEFAULT_LANGUAGE="${rawDefaultLang}" is not supported. Falling back to "en". Supported: ${SUPPORTED_LANG_CODES.join(', ')}`);
|
||||
console.warn(
|
||||
`DEFAULT_LANGUAGE="${rawDefaultLang}" is not supported. Falling back to "en". Supported: ${SUPPORTED_LANG_CODES.join(', ')}`,
|
||||
);
|
||||
}
|
||||
export const DEFAULT_LANGUAGE = SUPPORTED_LANG_CODES.includes(rawDefaultLang) ? rawDefaultLang : 'en';
|
||||
|
||||
@@ -116,7 +125,13 @@ export const DEFAULT_LANGUAGE = SUPPORTED_LANG_CODES.includes(rawDefaultLang) ?
|
||||
// challenge token or MCP OAuth tokens — those keep their own TTL.
|
||||
const DEFAULT_SESSION_DURATION = '24h';
|
||||
const DURATION_UNITS_MS: Record<string, number> = {
|
||||
ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000, y: 31_557_600_000,
|
||||
ms: 1,
|
||||
s: 1000,
|
||||
m: 60_000,
|
||||
h: 3_600_000,
|
||||
d: 86_400_000,
|
||||
w: 604_800_000,
|
||||
y: 31_557_600_000,
|
||||
};
|
||||
function parseDurationMs(value: string): number | null {
|
||||
const m = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w|y)?$/i.exec(value.trim());
|
||||
@@ -128,7 +143,9 @@ function parseDurationMs(value: string): number | null {
|
||||
const rawSessionDuration = process.env.SESSION_DURATION?.trim() || DEFAULT_SESSION_DURATION;
|
||||
const parsedSessionMs = parseDurationMs(rawSessionDuration);
|
||||
if (parsedSessionMs == null) {
|
||||
console.warn(`SESSION_DURATION="${rawSessionDuration}" is not a valid duration (use e.g. 1h, 7d, 30d). Falling back to "${DEFAULT_SESSION_DURATION}".`);
|
||||
console.warn(
|
||||
`SESSION_DURATION="${rawSessionDuration}" is not a valid duration (use e.g. 1h, 7d, 30d). Falling back to "${DEFAULT_SESSION_DURATION}".`,
|
||||
);
|
||||
}
|
||||
/** Human-readable session length actually in effect (for logs/diagnostics). */
|
||||
export const SESSION_DURATION = parsedSessionMs == null ? DEFAULT_SESSION_DURATION : rawSessionDuration;
|
||||
@@ -146,10 +163,13 @@ const DEFAULT_SESSION_DURATION_REMEMBER = '30d';
|
||||
const rawRememberDuration = process.env.SESSION_DURATION_REMEMBER?.trim() || DEFAULT_SESSION_DURATION_REMEMBER;
|
||||
const parsedRememberMs = parseDurationMs(rawRememberDuration);
|
||||
if (parsedRememberMs == null) {
|
||||
console.warn(`SESSION_DURATION_REMEMBER="${rawRememberDuration}" is not a valid duration (use e.g. 7d, 30d, 90d). Falling back to "${DEFAULT_SESSION_DURATION_REMEMBER}".`);
|
||||
console.warn(
|
||||
`SESSION_DURATION_REMEMBER="${rawRememberDuration}" is not a valid duration (use e.g. 7d, 30d, 90d). Falling back to "${DEFAULT_SESSION_DURATION_REMEMBER}".`,
|
||||
);
|
||||
}
|
||||
/** Human-readable "remember me" session length actually in effect (for logs/diagnostics). */
|
||||
export const SESSION_DURATION_REMEMBER = parsedRememberMs == null ? DEFAULT_SESSION_DURATION_REMEMBER : rawRememberDuration;
|
||||
export const SESSION_DURATION_REMEMBER =
|
||||
parsedRememberMs == null ? DEFAULT_SESSION_DURATION_REMEMBER : rawRememberDuration;
|
||||
/** "Remember me" session length in milliseconds — used for the persistent cookie `maxAge`. */
|
||||
export const SESSION_DURATION_REMEMBER_MS = parsedRememberMs ?? parseDurationMs(DEFAULT_SESSION_DURATION_REMEMBER)!;
|
||||
/** "Remember me" session length in seconds — passed to `jwt.sign({ expiresIn })`. */
|
||||
|
||||
@@ -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)',
|
||||
);
|
||||
},
|
||||
() => {
|
||||
// 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) {
|
||||
|
||||
@@ -66,6 +66,17 @@ export function hasTripPermission(action: string, tripId: number | string, userI
|
||||
return checkPermission(action, userRow?.role ?? 'user', tripOwnerId, userId, tripOwnerId !== userId);
|
||||
}
|
||||
|
||||
/** True when the user has the global admin role (mirrors REST `user.role === 'admin'` gates). */
|
||||
export function isAdminUser(userId: number): boolean {
|
||||
const userRow = db.prepare('SELECT role FROM users WHERE id = ?').get(userId) as { role?: string } | undefined;
|
||||
return userRow?.role === 'admin';
|
||||
}
|
||||
|
||||
/** Error response for admin-only tools, reproducing the REST `{ error: 'Admin access required' }` string. */
|
||||
export function adminRequired() {
|
||||
return { content: [{ type: 'text' as const, text: 'Admin access required' }], isError: true };
|
||||
}
|
||||
|
||||
export function ok(data: unknown) {
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
|
||||
}
|
||||
|
||||
@@ -136,7 +136,11 @@ export function registerAtlasTools(server: McpServer, userId: number, scopes: st
|
||||
async ({ regionCode, regionName, countryCode }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
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 });
|
||||
}
|
||||
);
|
||||
|
||||
+160
-25
@@ -5,18 +5,42 @@ import { isDemoUser } from '../../services/authService';
|
||||
import {
|
||||
createBudgetItem, updateBudgetItem, deleteBudgetItem,
|
||||
updateMembers as updateBudgetMembers,
|
||||
toggleMemberPaid,
|
||||
toggleMemberPaid, getBudgetItem,
|
||||
calculateSettlement, listSettlements, createSettlement, updateSettlement, deleteSettlement,
|
||||
} from '../../services/budgetService';
|
||||
import { getRates } from '../../services/exchangeRateService';
|
||||
import { getTripOwner, listMembers } from '../../services/tripService';
|
||||
import {
|
||||
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
|
||||
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_READONLY,
|
||||
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
|
||||
} 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 { ADDON_IDS } from '../../addons';
|
||||
|
||||
/**
|
||||
* Resolve the equal-split participants for a new budget item. When member_ids is
|
||||
* omitted, default to the whole trip (owner + all members), deduped — reproducing
|
||||
* the client's own create flow (CostsPanel seeds participants from all members).
|
||||
* An explicit empty array means "planning-only, no split" and is passed through.
|
||||
*/
|
||||
function resolveMemberIds(tripId: number, member_ids?: number[]): number[] | undefined {
|
||||
if (member_ids !== undefined) return member_ids;
|
||||
const owner = getTripOwner(tripId);
|
||||
if (!owner) return undefined;
|
||||
const { members } = listMembers(tripId, owner.user_id);
|
||||
return Array.from(new Set([owner.user_id, ...members.map(m => m.id)]));
|
||||
}
|
||||
|
||||
export function registerBudgetTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
||||
const R = canRead(scopes, 'budget');
|
||||
const W = canWrite(scopes, 'budget');
|
||||
|
||||
if (isAddonEnabled(ADDON_IDS.BUDGET)) {
|
||||
@@ -25,21 +49,26 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
|
||||
if (W) server.registerTool(
|
||||
'create_budget_item',
|
||||
{
|
||||
description: 'Add a budget/expense item to a trip.',
|
||||
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: {
|
||||
tripId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(200),
|
||||
category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'),
|
||||
total_price: z.number().nonnegative(),
|
||||
currency: z.string().max(10).nullable().optional().describe('ISO currency code (e.g. "EUR"); defaults to the trip currency'),
|
||||
member_ids: z.array(z.number().int().positive()).optional().describe('Trip member user IDs splitting this expense. Omit to split across all trip members (owner + members); pass [] for no split.'),
|
||||
payers: payersSchema.optional().describe('Who paid how much, in the expense currency. When given, total_price is derived from the sum. Ask the user; do not guess.'),
|
||||
expense_date: z.string().max(40).nullable().optional().describe('Date the expense occurred, YYYY-MM-DD'),
|
||||
note: z.string().max(500).optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, name, category, total_price, note }) => {
|
||||
async ({ tripId, name, category, total_price, currency, member_ids, payers, expense_date, note }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
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 });
|
||||
return ok({ item });
|
||||
}
|
||||
@@ -71,24 +100,26 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
|
||||
if (W) server.registerTool(
|
||||
'update_budget_item',
|
||||
{
|
||||
description: 'Update an existing budget/expense item in a trip.',
|
||||
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: {
|
||||
tripId: z.number().int().positive(),
|
||||
itemId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
category: z.string().max(100).optional(),
|
||||
total_price: z.number().nonnegative().optional(),
|
||||
member_ids: z.array(z.number().int().positive()).optional().describe('Trip member user IDs splitting this expense; replaces the current split. Omit to leave unchanged, pass [] for no split.'),
|
||||
payers: payersSchema.optional().describe('Replaces who paid how much, in the expense currency. Omit to leave unchanged. Ask the user; do not guess.'),
|
||||
persons: z.number().int().positive().nullable().optional(),
|
||||
days: z.number().int().positive().nullable().optional(),
|
||||
note: z.string().max(500).nullable().optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, itemId, name, category, total_price, persons, days, note }) => {
|
||||
async ({ tripId, itemId, name, category, total_price, member_ids, payers, persons, days, note }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
||||
const item = updateBudgetItem(itemId, tripId, { name, category, total_price, 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 };
|
||||
safeBroadcast(tripId, 'budget:updated', { item });
|
||||
return ok({ item });
|
||||
@@ -100,14 +131,14 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
|
||||
if (W) server.registerTool(
|
||||
'create_budget_item_with_members',
|
||||
{
|
||||
description: 'Create a budget/expense item and 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: {
|
||||
tripId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(200),
|
||||
category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'),
|
||||
total_price: z.number().nonnegative(),
|
||||
note: z.string().max(500).optional(),
|
||||
userIds: z.array(z.number().int().positive()).optional().describe('User IDs splitting this item; omit 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,
|
||||
},
|
||||
@@ -115,19 +146,16 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
||||
const 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 {
|
||||
const run = db.transaction(() => {
|
||||
const item = createBudgetItem(tripId, { category, name, total_price, note });
|
||||
if (hasMembers) {
|
||||
return updateBudgetMembers(item.id, tripId, userIds!);
|
||||
}
|
||||
return { item };
|
||||
});
|
||||
const result = run();
|
||||
safeBroadcast(tripId, 'budget:created', { item: (result as any).item ?? result });
|
||||
if (hasMembers) safeBroadcast(tripId, 'budget:members-updated', { item: result });
|
||||
return ok({ item: result });
|
||||
const item = db.transaction(() => {
|
||||
const created = createBudgetItem(tripId, { category, name, total_price, note, member_ids: members });
|
||||
return getBudgetItem(created.id, tripId)!;
|
||||
})();
|
||||
safeBroadcast(tripId, 'budget:created', { item });
|
||||
if (members && members.length > 0) safeBroadcast(tripId, 'budget:members-updated', { item });
|
||||
return ok({ item });
|
||||
} catch {
|
||||
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(
|
||||
'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: {
|
||||
tripId: 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 (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
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 });
|
||||
return ok({ item });
|
||||
}
|
||||
@@ -176,5 +206,110 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
|
||||
return ok({ member });
|
||||
}
|
||||
);
|
||||
|
||||
// --- SETTLEMENTS (settle-up payments between members) ---
|
||||
|
||||
if (R) server.registerTool(
|
||||
'get_settlement_summary',
|
||||
{
|
||||
description: "See each member's net balance and the suggested payments to settle shared expenses. Amounts are in the trip's base currency. Call this before recording a settlement so you know who should pay whom and how much.",
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
base: z.string().max(10).optional().describe('ISO currency code to compute balances in; defaults to the trip currency'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ tripId, base }) => {
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const trip = db.prepare('SELECT currency FROM trips WHERE id = ?').get(tripId) as { currency?: string } | undefined;
|
||||
const tripCurrency = trip?.currency || 'EUR';
|
||||
const effectiveBase = (base || tripCurrency).toUpperCase();
|
||||
const rates = await getRates(effectiveBase);
|
||||
const summary = calculateSettlement(tripId, { base: effectiveBase, rates, tripCurrency });
|
||||
return ok({ summary });
|
||||
}
|
||||
);
|
||||
|
||||
if (R) server.registerTool(
|
||||
'list_settlements',
|
||||
{
|
||||
description: 'List the recorded settle-up payments for a trip (who paid whom, how much, when).',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ tripId }) => {
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
return ok({ settlements: listSettlements(tripId) });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'create_settlement',
|
||||
{
|
||||
description: "Record a settle-up payment: from_user_id paid to_user_id the given amount (in the trip's base currency) to settle shared expenses. Use get_settlement_summary first to find who owes whom and how much.",
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
from_user_id: z.number().int().positive().describe('User ID of the member who paid'),
|
||||
to_user_id: z.number().int().positive().describe('User ID of the member who received the payment'),
|
||||
amount: z.number().positive().describe("Amount paid, in the trip's base currency"),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, from_user_id, to_user_id, amount }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
||||
const settlement = createSettlement(tripId, { from_user_id, to_user_id, amount }, userId);
|
||||
safeBroadcast(tripId, 'budget:settlement-created', { settlement });
|
||||
return ok({ settlement });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'update_settlement',
|
||||
{
|
||||
description: 'Update a recorded settle-up payment (who paid, who received, and the amount).',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
settlementId: z.number().int().positive(),
|
||||
from_user_id: z.number().int().positive().describe('User ID of the member who paid'),
|
||||
to_user_id: z.number().int().positive().describe('User ID of the member who received the payment'),
|
||||
amount: z.number().positive().describe("Amount paid, in the trip's base currency"),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, settlementId, from_user_id, to_user_id, amount }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
||||
const settlement = updateSettlement(settlementId, tripId, { from_user_id, to_user_id, amount });
|
||||
if (!settlement) return { content: [{ type: 'text' as const, text: 'Settlement not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'budget:settlement-updated', { settlement });
|
||||
return ok({ settlement });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'delete_settlement',
|
||||
{
|
||||
description: 'Delete a recorded settle-up payment. This is the undo for create_settlement and restores the affected balances.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
settlementId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ tripId, settlementId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
||||
const deleted = deleteSettlement(settlementId, tripId);
|
||||
if (!deleted) return { content: [{ type: 'text' as const, text: 'Settlement not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'budget:settlement-deleted', { settlementId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
} // isAddonEnabled(BUDGET)
|
||||
}
|
||||
|
||||
@@ -99,19 +99,20 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
||||
start_day_id: z.number().int().positive().describe('Check-in day ID'),
|
||||
end_day_id: z.number().int().positive().describe('Check-out day ID'),
|
||||
check_in: z.string().max(10).optional().describe('Check-in time e.g. "15:00"'),
|
||||
check_in_end: z.string().max(10).optional().describe('Check-in window end time e.g. "20:00"'),
|
||||
check_out: z.string().max(10).optional().describe('Check-out time e.g. "11:00"'),
|
||||
confirmation: z.string().max(100).optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, place_id, start_day_id, end_day_id, check_in, check_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 (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
|
||||
const errors = validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id);
|
||||
if (errors.length > 0) return { content: [{ type: 'text' as const, text: errors.map(e => e.message).join(', ') }], isError: true };
|
||||
const accommodation = createAccommodation(tripId, { place_id, start_day_id, end_day_id, check_in, check_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 });
|
||||
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'),
|
||||
end_day_id: z.number().int().positive().describe('Check-out day ID'),
|
||||
check_in: z.string().max(10).optional().describe('Check-in time e.g. "15:00"'),
|
||||
check_in_end: z.string().max(10).optional().describe('Check-in window end time e.g. "20:00"'),
|
||||
check_out: z.string().max(10).optional().describe('Check-out time e.g. "11:00"'),
|
||||
confirmation: z.string().max(100).optional(),
|
||||
accommodation_notes: z.string().max(1000).optional().describe('Notes for the accommodation'),
|
||||
@@ -145,7 +147,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, start_day_id, end_day_id, check_in, check_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 (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
|
||||
@@ -154,7 +156,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
||||
try {
|
||||
const run = db.transaction(() => {
|
||||
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone, price, currency });
|
||||
const accommodation = createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_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 };
|
||||
});
|
||||
const result = run();
|
||||
@@ -178,19 +180,20 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
||||
start_day_id: z.number().int().positive().optional(),
|
||||
end_day_id: z.number().int().positive().optional(),
|
||||
check_in: z.string().max(10).optional(),
|
||||
check_in_end: z.string().max(10).optional().describe('Check-in window end time e.g. "20:00"'),
|
||||
check_out: z.string().max(10).optional(),
|
||||
confirmation: z.string().max(100).optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, accommodationId, place_id, start_day_id, end_day_id, check_in, check_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 (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
|
||||
const existing = getAccommodation(accommodationId, tripId);
|
||||
if (!existing) return { content: [{ type: 'text' as const, text: 'Accommodation not found.' }], isError: true };
|
||||
const accommodation = updateAccommodation(accommodationId, existing, { place_id, start_day_id, end_day_id, check_in, check_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 });
|
||||
return ok({ accommodation });
|
||||
}
|
||||
|
||||
@@ -136,7 +136,9 @@ export function registerJourneyTools(server: McpServer, userId: number, scopes:
|
||||
async ({ title, subtitle, trip_ids }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const journey = createJourney(userId, { title, subtitle, trip_ids });
|
||||
return 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();
|
||||
const entry = createEntry(journeyId, userId, { entry_date, title, story, entry_time, location_name, mood, sort_order });
|
||||
if (!entry) return notFound('Journey not found or access denied.');
|
||||
return 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();
|
||||
const entry = updateEntry(entryId, userId, { title, story, entry_date, entry_time, mood }, undefined);
|
||||
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();
|
||||
const result = updateJourneyPreferences(journeyId, userId, { hide_skeletons });
|
||||
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,
|
||||
getCategoryAssignees as getPackingCategoryAssignees,
|
||||
updateCategoryAssignees as updatePackingCategoryAssignees,
|
||||
applyTemplate, saveAsTemplate, bulkImport,
|
||||
applyTemplate, saveAsTemplate, listTemplates, bulkImport,
|
||||
} from '../../services/packingService';
|
||||
import {
|
||||
safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
|
||||
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
|
||||
isAdminUser, adminRequired,
|
||||
} from './_shared';
|
||||
import { canRead, canWrite } from '../scopes';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { isAddonEnabled, deletePackingTemplate } from '../../services/adminService';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
|
||||
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 (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
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 });
|
||||
return ok({ bag });
|
||||
}
|
||||
@@ -197,7 +200,10 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
|
||||
const bodyKeys: string[] = [];
|
||||
if (name !== undefined) { fields.name = name; bodyKeys.push('name'); }
|
||||
if (color !== undefined) { fields.color = color; bodyKeys.push('color'); }
|
||||
const 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 });
|
||||
return ok({ bag });
|
||||
}
|
||||
@@ -238,9 +244,10 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||
setBagMembers(tripId, bagId, userIds);
|
||||
safeBroadcast(tripId, 'packing:bag-members-updated', { bagId, userIds });
|
||||
return ok({ success: true });
|
||||
const members = setBagMembers(tripId, bagId, userIds);
|
||||
if (!members) return { content: [{ type: 'text' as const, text: 'Bag not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'packing:bag-members-updated', { bagId, members });
|
||||
return ok({ members });
|
||||
}
|
||||
);
|
||||
|
||||
@@ -275,9 +282,9 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||
updatePackingCategoryAssignees(tripId, categoryName, userIds);
|
||||
safeBroadcast(tripId, 'packing:assignees', { categoryName, userIds });
|
||||
return ok({ success: true });
|
||||
const assignees = updatePackingCategoryAssignees(tripId, categoryName, userIds);
|
||||
safeBroadcast(tripId, 'packing:assignees', { category: categoryName, assignees });
|
||||
return ok({ assignees });
|
||||
}
|
||||
);
|
||||
|
||||
@@ -295,17 +302,32 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||
const applied = applyTemplate(tripId, templateId);
|
||||
if (applied === null) return { content: [{ type: 'text' as const, text: 'Template not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'packing:template-applied', { templateId });
|
||||
return ok({ success: true });
|
||||
const items = applyTemplate(tripId, templateId);
|
||||
if (items === null) return { content: [{ type: 'text' as const, text: 'Template not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'packing:template-applied', { items });
|
||||
return ok({ items, count: items.length });
|
||||
}
|
||||
);
|
||||
|
||||
if (R) server.registerTool(
|
||||
'list_packing_templates',
|
||||
{
|
||||
description: 'List the reusable packing templates (id, name, item count) so one can be applied with apply_packing_template.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ tripId }) => {
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
return ok({ templates: listTemplates() });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'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: {
|
||||
tripId: z.number().int().positive(),
|
||||
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 (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||
saveAsTemplate(tripId, userId, templateName);
|
||||
return ok({ success: true });
|
||||
// Templates are global; the REST route restricts saving to admins. Match it.
|
||||
if (!isAdminUser(userId)) return adminRequired();
|
||||
const template = saveAsTemplate(tripId, userId, templateName);
|
||||
if (!template) return { content: [{ type: 'text' as const, text: 'Nothing to save — the packing list is empty.' }], isError: true };
|
||||
return ok({ template });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'delete_packing_template',
|
||||
{
|
||||
description: 'Delete a reusable packing template. Templates are global, so deletion is admin only.',
|
||||
inputSchema: {
|
||||
templateId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ templateId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
// Templates are global; the REST route restricts management to admins. Match it.
|
||||
if (!isAdminUser(userId)) return adminRequired();
|
||||
const result = deletePackingTemplate(String(templateId));
|
||||
if ('error' in result) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
|
||||
return ok({ success: true, name: result.name });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'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: {
|
||||
tripId: z.number().int().positive(),
|
||||
items: z.array(z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
category: z.string().optional(),
|
||||
quantity: z.number().int().positive().optional(),
|
||||
bag: z.string().max(100).optional().describe('Bag name to assign the item to; created if it does not exist'),
|
||||
weight_grams: z.number().nonnegative().optional(),
|
||||
checked: z.boolean().optional(),
|
||||
})).min(1),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
@@ -339,9 +386,9 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||
bulkImport(tripId, items);
|
||||
safeBroadcast(tripId, 'packing:updated', {});
|
||||
return ok({ success: true, count: items.length });
|
||||
const created = bulkImport(tripId, items);
|
||||
for (const item of created) safeBroadcast(tripId, 'packing:created', { item });
|
||||
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, '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 {
|
||||
createReservation, deleteReservation, getReservation, updateReservation,
|
||||
type EndpointInput,
|
||||
} from '../../services/reservationService';
|
||||
import { linkBudgetItemToReservation } from '../../services/budgetService';
|
||||
import { getDay } from '../../services/dayService';
|
||||
import { findByIata } from '../../services/airportService';
|
||||
import {
|
||||
safeBroadcast, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
TOOL_ANNOTATIONS_WRITE, demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
|
||||
@@ -15,17 +17,56 @@ import { canWrite } from '../scopes';
|
||||
|
||||
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)'),
|
||||
sequence: z.number().int().min(0).describe('Order within the route (0-based)'),
|
||||
name: z.string().min(1).describe('Location name (e.g. "Paris Gare de Lyon", "ZRH Terminal 2")'),
|
||||
code: z.string().optional().describe('IATA airport code for flights (e.g. "ZRH"). Leave empty for other transport types.'),
|
||||
lat: z.number().optional(),
|
||||
lng: 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().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.'),
|
||||
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'),
|
||||
})).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 {
|
||||
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))
|
||||
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
|
||||
|
||||
const resolved = resolveEndpointCoords(endpoints);
|
||||
if ('error' in resolved) return { content: [{ type: 'text' as const, text: resolved.error }], isError: true };
|
||||
|
||||
const meta: Record<string, string> = { ...(metadata ?? {}) };
|
||||
if (price != null) meta.price = String(price);
|
||||
|
||||
@@ -78,7 +122,7 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
|
||||
end_day_id: end_day_id ?? start_day_id,
|
||||
status: status ?? 'pending',
|
||||
metadata: Object.keys(meta).length > 0 ? meta : undefined,
|
||||
endpoints,
|
||||
endpoints: resolved.endpoints,
|
||||
needs_review,
|
||||
});
|
||||
|
||||
@@ -135,6 +179,14 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
|
||||
if (end_day_id && !getDay(end_day_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
|
||||
|
||||
// Only resolve when endpoints are explicitly provided; undefined leaves them untouched.
|
||||
let resolvedEndpoints: EndpointInput[] | undefined;
|
||||
if (endpoints !== undefined) {
|
||||
const resolved = resolveEndpointCoords(endpoints);
|
||||
if ('error' in resolved) return { content: [{ type: 'text' as const, text: resolved.error }], isError: true };
|
||||
resolvedEndpoints = resolved.endpoints;
|
||||
}
|
||||
|
||||
const { reservation } = updateReservation(reservationId, tripId, {
|
||||
title,
|
||||
type,
|
||||
@@ -146,7 +198,7 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
|
||||
end_day_id,
|
||||
status,
|
||||
metadata,
|
||||
endpoints,
|
||||
endpoints: resolvedEndpoints,
|
||||
needs_review,
|
||||
}, existing);
|
||||
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 }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const planId = getActivePlanId(userId);
|
||||
await updatePlan(planId, { block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled }, undefined);
|
||||
return ok({ success: true });
|
||||
// updatePlan already returns the fully-hydrated { plan }; surface it so the
|
||||
// AI consumer sees the updated plan, matching get_vacay_plan.
|
||||
const result = await updatePlan(planId, { block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled }, undefined);
|
||||
return ok(result);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -73,7 +75,8 @@ export function registerVacayTools(server: McpServer, userId: number, scopes: st
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const planId = getActivePlanId(userId);
|
||||
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 };
|
||||
}
|
||||
|
||||
@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')
|
||||
deleteSettlement(
|
||||
@CurrentUser() user: User,
|
||||
|
||||
@@ -73,6 +73,10 @@ export class BudgetService {
|
||||
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 {
|
||||
return svc.deleteSettlement(id, tripId);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export class AirtrailController {
|
||||
body.url,
|
||||
body.apiKey,
|
||||
!!body.allowInsecureTls,
|
||||
!!body.writeEnabled,
|
||||
getClientIp(req),
|
||||
);
|
||||
if (!result.success) {
|
||||
|
||||
@@ -78,6 +78,8 @@ export interface AirtrailFlightRaw {
|
||||
datePrecision: string | null;
|
||||
departure: string | null;
|
||||
arrival: string | null;
|
||||
departureScheduled: string | null;
|
||||
arrivalScheduled: string | null;
|
||||
airline: AirtrailNamedCode | null;
|
||||
flightNumber: string | null;
|
||||
aircraft: AirtrailNamedCode | null;
|
||||
@@ -92,10 +94,14 @@ export interface AirtrailSavePayload {
|
||||
id?: number;
|
||||
from: string;
|
||||
to: string;
|
||||
departure: string;
|
||||
departure: string | null;
|
||||
departureTime?: string | null;
|
||||
arrival?: string | null;
|
||||
arrivalTime?: string | null;
|
||||
departureScheduled?: string | null;
|
||||
departureScheduledTime?: string | null;
|
||||
arrivalScheduled?: string | null;
|
||||
arrivalScheduledTime?: string | null;
|
||||
datePrecision?: string;
|
||||
airline?: string | null;
|
||||
flightNumber?: string | null;
|
||||
|
||||
@@ -11,7 +11,7 @@ function airportCode(a: AirtrailAirport | null): string | null {
|
||||
* Airline/aircraft arrive as joined objects ({icao, iata, name, ...}); reduce
|
||||
* 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;
|
||||
}
|
||||
|
||||
@@ -55,8 +55,8 @@ export function normalizeFlight(raw: AirtrailFlightRaw): AirtrailFlight {
|
||||
toCode: airportCode(raw.to),
|
||||
toName: raw.to?.name ?? null,
|
||||
date: raw.date ?? null,
|
||||
departure: raw.departure ?? null,
|
||||
arrival: raw.arrival ?? null,
|
||||
departure: raw.departureScheduled ?? null,
|
||||
arrival: raw.arrivalScheduled ?? null,
|
||||
airline: entityCode(raw.airline),
|
||||
flightNumber: raw.flightNumber ?? null,
|
||||
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'). */
|
||||
export function mapFlightToReservation(raw: AirtrailFlightRaw): MappedReservation {
|
||||
const dep = localParts(raw.departure, raw.from?.tz ?? null);
|
||||
const arr = localParts(raw.arrival, raw.to?.tz ?? null);
|
||||
// Read the SCHEDULED times only — TREK plans against the scheduled (booked) time,
|
||||
// not the actual/estimated `departure`/`arrival`. When a flight has no scheduled
|
||||
// time, the clock is left blank (date preserved) rather than fabricated.
|
||||
const dep = localParts(raw.departureScheduled, raw.from?.tz ?? null);
|
||||
const arr = localParts(raw.arrivalScheduled, raw.to?.tz ?? null);
|
||||
|
||||
const fromCode = airportCode(raw.from);
|
||||
const toCode = airportCode(raw.to);
|
||||
const datePrefix = raw.date || dep.date;
|
||||
const reservation_time = datePrefix ? `${datePrefix}T${dep.time ?? '00:00'}` : null;
|
||||
const reservation_end_time = arr.date ? `${arr.date}T${arr.time ?? '00:00'}` : null;
|
||||
const reservation_time = dep.date && dep.time ? `${dep.date}T${dep.time}` : (datePrefix ?? null);
|
||||
const reservation_end_time = arr.date && arr.time ? `${arr.date}T${arr.time}` : null;
|
||||
|
||||
const endpoints: MappedEndpoint[] = [];
|
||||
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 (raw.aircraftReg) metadata.aircraft_reg = raw.aircraftReg;
|
||||
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
|
||||
// makes the clearest title; fall back to the route.
|
||||
@@ -178,8 +181,8 @@ export function canonicalHash(raw: AirtrailFlightRaw): string {
|
||||
to: airportCode(raw.to),
|
||||
date: raw.date ?? null,
|
||||
datePrecision: raw.datePrecision ?? 'day',
|
||||
departure: raw.departure ?? null,
|
||||
arrival: raw.arrival ?? null,
|
||||
departureScheduled: raw.departureScheduled ?? null,
|
||||
arrivalScheduled: raw.arrivalScheduled ?? null,
|
||||
airline: entityCode(raw.airline),
|
||||
flightNumber: raw.flightNumber ?? null,
|
||||
aircraft: entityCode(raw.aircraft),
|
||||
|
||||
@@ -12,14 +12,25 @@ interface UserConnRow {
|
||||
airtrail_url?: string | null;
|
||||
airtrail_api_key?: string | null;
|
||||
airtrail_allow_insecure_tls?: number | null;
|
||||
airtrail_write_enabled?: number | null;
|
||||
}
|
||||
|
||||
function readRow(userId: number): UserConnRow | undefined {
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export function getAirtrailCredentials(userId: number): AirtrailCreds | null {
|
||||
const row = readRow(userId);
|
||||
@@ -40,6 +51,7 @@ export function getConnectionSettings(userId: number) {
|
||||
url: row?.airtrail_url || '',
|
||||
apiKeyMasked: row?.airtrail_api_key ? KEY_MASK : '',
|
||||
allowInsecureTls: !!row?.airtrail_allow_insecure_tls,
|
||||
writeEnabled: !!row?.airtrail_write_enabled,
|
||||
connected: !!(row?.airtrail_url && row?.airtrail_api_key),
|
||||
};
|
||||
}
|
||||
@@ -49,6 +61,7 @@ export async function saveSettings(
|
||||
url: string | undefined,
|
||||
apiKey: string | undefined,
|
||||
allowInsecureTls: boolean,
|
||||
writeEnabled: boolean,
|
||||
clientIp: string | null,
|
||||
): Promise<{ success: boolean; warning?: string; error?: string }> {
|
||||
const trimmedUrl = (url || '').trim();
|
||||
@@ -81,12 +94,12 @@ export async function saveSettings(
|
||||
|
||||
if (newKey !== undefined) {
|
||||
db.prepare(
|
||||
'UPDATE users SET airtrail_url = ?, airtrail_api_key = ?, airtrail_allow_insecure_tls = ? WHERE id = ?',
|
||||
).run(trimmedUrl || null, newKey, allowInsecureTls ? 1 : 0, userId);
|
||||
'UPDATE users SET airtrail_url = ?, airtrail_api_key = ?, airtrail_allow_insecure_tls = ?, airtrail_write_enabled = ? WHERE id = ?',
|
||||
).run(trimmedUrl || null, newKey, allowInsecureTls ? 1 : 0, writeEnabled ? 1 : 0, userId);
|
||||
} else {
|
||||
db.prepare(
|
||||
'UPDATE users SET airtrail_url = ?, airtrail_allow_insecure_tls = ? WHERE id = ?',
|
||||
).run(trimmedUrl || null, allowInsecureTls ? 1 : 0, userId);
|
||||
'UPDATE users SET airtrail_url = ?, airtrail_allow_insecure_tls = ?, airtrail_write_enabled = ? WHERE id = ?',
|
||||
).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.
|
||||
if (!trimmedUrl) {
|
||||
db.prepare('UPDATE users SET airtrail_api_key = NULL WHERE id = ?').run(userId);
|
||||
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
listFlights,
|
||||
saveFlight,
|
||||
} from './airtrailClient';
|
||||
import { canonicalHash, mapFlightToReservation } from './airtrailMapper';
|
||||
import { getAirtrailCredentials } from './airtrailService';
|
||||
import { canonicalHash, entityCode, mapFlightToReservation } from './airtrailMapper';
|
||||
import { getAirtrailCredentials, isAirtrailWriteEnabled } from './airtrailService';
|
||||
|
||||
/** Global on/off: the addon must be enabled and sync not explicitly turned off. */
|
||||
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 };
|
||||
}
|
||||
|
||||
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>;
|
||||
try {
|
||||
meta = reservation.metadata ? JSON.parse(reservation.metadata) : {};
|
||||
@@ -183,7 +192,14 @@ function buildSavePayload(reservation: any, existing: AirtrailFlightRaw): Airtra
|
||||
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 {
|
||||
// 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),
|
||||
from: fromCode,
|
||||
to: toCode,
|
||||
@@ -191,14 +207,25 @@ function buildSavePayload(reservation: any, existing: AirtrailFlightRaw): Airtra
|
||||
departureTime: dep.time,
|
||||
arrival: arr.date,
|
||||
arrivalTime: arr.time,
|
||||
airline: meta.airline ?? null,
|
||||
flightNumber: meta.flight_number ?? null,
|
||||
aircraft: meta.aircraft ?? null,
|
||||
aircraftReg: meta.aircraft_reg ?? null,
|
||||
flightReason: meta.flight_reason ?? null,
|
||||
note: reservation.notes ?? null,
|
||||
// Import reads the SCHEDULED time, so a TREK edit must write back there too —
|
||||
// otherwise the next pull (scheduled-wins) would revert it. AirTrail rebuilds the
|
||||
// instant from a full-ISO date carrier + the HH:MM time, so pass a date carrier.
|
||||
departureScheduled: dep.date ? `${dep.date}T00:00:00.000Z` : null,
|
||||
departureScheduledTime: dep.time,
|
||||
arrivalScheduled: arr.date ? `${arr.date}T00:00:00.000Z` : null,
|
||||
arrivalScheduledTime: arr.time,
|
||||
// These are AirTrail-owned details TREK doesn't surface in its edit UI — a TREK
|
||||
// edit can leave them out of `metadata`. Preserve AirTrail's current value when
|
||||
// TREK has none rather than nulling it out (#1240). entityCode mirrors the
|
||||
// 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,
|
||||
};
|
||||
} as AirtrailSavePayload;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -219,9 +246,12 @@ export async function pushReservationToAirtrail(reservationId: number, tripId: n
|
||||
| undefined;
|
||||
if (!row || !row.sync_enabled) return;
|
||||
|
||||
const creds: AirtrailCreds | null = row.external_owner_user_id
|
||||
? getAirtrailCredentials(row.external_owner_user_id)
|
||||
: null;
|
||||
// AirTrail is read-only by default (#1240). Only push when the flight's owner has
|
||||
// explicitly opted in. A no-op skip (not a detach): the link stays active so the
|
||||
// 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) {
|
||||
detach(tripId, row.id); // owner disconnected — cannot push, so stop syncing
|
||||
return;
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from 'path';
|
||||
import zlib from 'zlib';
|
||||
import { db } from '../db/database';
|
||||
import { Trip, Place } from '../types';
|
||||
import { CONTINENT_MAP } from '@trek/shared';
|
||||
|
||||
// ── 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',
|
||||
};
|
||||
|
||||
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 ───────────────────────────────────────────────────────
|
||||
|
||||
let lastNominatimCall = 0;
|
||||
|
||||
@@ -158,6 +158,15 @@ export function createBudgetItem(
|
||||
return item;
|
||||
}
|
||||
|
||||
/** Fetch a single budget item hydrated with its members and payers, scoped to the trip. */
|
||||
export function getBudgetItem(id: string | number, tripId: string | number): BudgetItem | null {
|
||||
const item = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId) as BudgetItem | undefined;
|
||||
if (!item) return null;
|
||||
item.members = loadItemMembers(id);
|
||||
item.payers = loadItemPayers(id);
|
||||
return item;
|
||||
}
|
||||
|
||||
export function linkBudgetItemToReservation(
|
||||
tripId: string | number,
|
||||
reservationId: number,
|
||||
@@ -385,11 +394,18 @@ export function calculateSettlement(
|
||||
}
|
||||
|
||||
// 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 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) {
|
||||
if (balances[s.from_user_id]) balances[s.from_user_id].balance += s.amount;
|
||||
if (balances[s.to_user_id]) balances[s.to_user_id].balance -= s.amount;
|
||||
ensureSettled(s.from_user_id, s.from_username, s.from_avatar_url).balance += s.amount;
|
||||
ensureSettled(s.to_user_id, s.to_username, s.to_avatar_url).balance -= s.amount;
|
||||
}
|
||||
|
||||
// Calculate optimized payment flows (greedy algorithm)
|
||||
@@ -461,6 +477,19 @@ export function createSettlement(
|
||||
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 {
|
||||
const row = db.prepare('SELECT id FROM budget_settlements WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||
if (!row) return false;
|
||||
|
||||
@@ -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 }> {
|
||||
let resolvedUrl = url;
|
||||
|
||||
// Follow redirects for short URLs (goo.gl, maps.app.goo.gl) with SSRF protection.
|
||||
// Redirects are followed manually so every hop is re-checked — a short link
|
||||
// that 302s to an internal IP is blocked, while a legitimate cross-host
|
||||
// redirect (goo.gl → maps.google.com) still resolves.
|
||||
const parsed = new URL(url);
|
||||
if (['goo.gl', 'maps.app.goo.gl'].includes(parsed.hostname)) {
|
||||
// Extract coordinates from a string (URL or page body). Google Maps encodes
|
||||
// them several ways: /@lat,lng,zoom · !3dlat!4dlng (map data param) · ?q=/?ll=.
|
||||
const extractCoords = (s: string): { lat: number; lng: number } | null => {
|
||||
const at = s.match(/@(-?\d+\.\d+),(-?\d+\.\d+)/);
|
||||
if (at) return { lat: parseFloat(at[1]), lng: parseFloat(at[2]) };
|
||||
const data = s.match(/!3d(-?\d+\.\d+)!4d(-?\d+\.\d+)/);
|
||||
if (data) return { lat: parseFloat(data[1]), lng: parseFloat(data[2]) };
|
||||
const q = s.match(/[?&](?:q|ll)=(-?\d+\.\d+),(-?\d+\.\d+)/);
|
||||
if (q) return { lat: parseFloat(q[1]), lng: parseFloat(q[2]) };
|
||||
return null;
|
||||
};
|
||||
|
||||
const followRedirects = async (target: string, init?: RequestInit): Promise<Response> => {
|
||||
try {
|
||||
const redirectRes = await safeFetchFollow(
|
||||
url,
|
||||
{ signal: AbortSignal.timeout(10000) },
|
||||
return await safeFetchFollow(
|
||||
target,
|
||||
{ signal: AbortSignal.timeout(10000), ...init },
|
||||
{ bypassInternalIpAllowed: true },
|
||||
);
|
||||
resolvedUrl = redirectRes.url;
|
||||
} catch (err) {
|
||||
if (err instanceof SsrfBlockedError) {
|
||||
throw Object.assign(new Error('URL blocked by SSRF check'), { status: 403 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Follow redirects for short URLs (goo.gl, maps.app.goo.gl) and for Google Maps
|
||||
// URLs that carry no inline coordinates — e.g. ?cid= links (the format
|
||||
// get_place_details returns) and "Share"-button links. The redirect target
|
||||
// usually carries the !3d!4d data param we can then parse. Redirects are
|
||||
// followed manually so every hop is SSRF-re-checked.
|
||||
const parsed = new URL(url);
|
||||
const GOOGLE_MAPS_HOSTS = ['goo.gl', 'maps.app.goo.gl', 'google.com', 'www.google.com', 'maps.google.com'];
|
||||
const isShort = ['goo.gl', 'maps.app.goo.gl'].includes(parsed.hostname);
|
||||
const isGoogleMaps = GOOGLE_MAPS_HOSTS.includes(parsed.hostname);
|
||||
if (isShort || (isGoogleMaps && !extractCoords(url))) {
|
||||
resolvedUrl = (await followRedirects(url)).url || resolvedUrl;
|
||||
}
|
||||
|
||||
// Extract coordinates from Google Maps URL patterns:
|
||||
// /@48.8566,2.3522,15z or /place/.../@48.8566,2.3522
|
||||
// ?q=48.8566,2.3522 or ?ll=48.8566,2.3522
|
||||
let lat: number | null = null;
|
||||
let lng: number | null = null;
|
||||
let placeName: string | null = null;
|
||||
let coords = extractCoords(resolvedUrl);
|
||||
|
||||
// Pattern: /@lat,lng
|
||||
const atMatch = resolvedUrl.match(/@(-?\d+\.?\d*),(-?\d+\.?\d*)/);
|
||||
if (atMatch) { lat = parseFloat(atMatch[1]); lng = parseFloat(atMatch[2]); }
|
||||
|
||||
// Pattern: !3dlat!4dlng (Google Maps data params)
|
||||
if (!lat) {
|
||||
const dataMatch = resolvedUrl.match(/!3d(-?\d+\.?\d*)!4d(-?\d+\.?\d*)/);
|
||||
if (dataMatch) { lat = parseFloat(dataMatch[1]); lng = parseFloat(dataMatch[2]); }
|
||||
}
|
||||
|
||||
// Pattern: ?q=lat,lng or &q=lat,lng
|
||||
if (!lat) {
|
||||
const qMatch = resolvedUrl.match(/[?&]q=(-?\d+\.?\d*),(-?\d+\.?\d*)/);
|
||||
if (qMatch) { lat = parseFloat(qMatch[1]); lng = parseFloat(qMatch[2]); }
|
||||
// Still nothing (e.g. a cid page whose final URL lacks coordinates): fetch the
|
||||
// page body once and parse the coordinates out of the embedded map data.
|
||||
if (!coords) {
|
||||
try {
|
||||
const pageRes = await followRedirects(resolvedUrl, {
|
||||
headers: { 'User-Agent': 'TREK-Travel-Planner/1.0' },
|
||||
});
|
||||
coords = extractCoords(await pageRes.text());
|
||||
} catch (err) {
|
||||
if ((err as { status?: number })?.status === 403) throw err; // SSRF block — surface it
|
||||
// Otherwise fall through to the not-found error below.
|
||||
}
|
||||
}
|
||||
|
||||
// Extract place name from URL path: /place/Place+Name/@...
|
||||
let placeName: string | null = null;
|
||||
const placeMatch = resolvedUrl.match(/\/place\/([^/@]+)/);
|
||||
if (placeMatch) {
|
||||
placeName = decodeURIComponent(placeMatch[1].replace(/\+/g, ' '));
|
||||
}
|
||||
|
||||
if (!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 });
|
||||
}
|
||||
const { lat, lng } = coords;
|
||||
|
||||
// Reverse geocode to get address
|
||||
const nominatimRes = await fetch(
|
||||
|
||||
@@ -17,9 +17,9 @@ export interface ReservationEndpoint {
|
||||
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(`
|
||||
SELECT e.* FROM reservation_endpoints e
|
||||
JOIN reservations r ON e.reservation_id = r.id
|
||||
@@ -110,6 +110,9 @@ export function listReservations(tripId: string | number) {
|
||||
for (const r of reservations) {
|
||||
r.day_positions = posMap.get(r.id) || null;
|
||||
r.endpoints = endpointsMap.get(r.id) || [];
|
||||
// accommodation_id is a TEXT column; the integer FK reads back as a numeric
|
||||
// string (e.g. "14.0"). Normalize to an int so clients can parse it.
|
||||
r.accommodation_id = r.accommodation_id == null ? null : Math.trunc(Number(r.accommodation_id));
|
||||
}
|
||||
|
||||
return reservations;
|
||||
@@ -163,6 +166,9 @@ export function getReservationWithJoins(id: string | number) {
|
||||
`).get(id) as any;
|
||||
if (!row) return undefined;
|
||||
row.endpoints = loadEndpoints(row.id);
|
||||
// accommodation_id is a TEXT column; the integer FK reads back as a numeric
|
||||
// string (e.g. "14.0"). Normalize to an int so clients can parse it.
|
||||
row.accommodation_id = row.accommodation_id == null ? null : Math.trunc(Number(row.accommodation_id));
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -364,12 +370,19 @@ export function updateReservation(id: string | number, tripId: string | number,
|
||||
// otherwise derive from the (possibly updated) reservation_time so the
|
||||
// planner renders the booking on the correct day.
|
||||
let nextDayId: number | null;
|
||||
if (day_id !== undefined) {
|
||||
nextDayId = day_id || null;
|
||||
} else if (reservation_time !== undefined && resolvedType !== 'hotel') {
|
||||
if (day_id != null) {
|
||||
// Explicit day from the client (e.g. moved on the planner).
|
||||
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);
|
||||
} else {
|
||||
} else if (day_id === undefined) {
|
||||
// Field absent and nothing to derive from — keep whatever it had.
|
||||
nextDayId = current.day_id ?? null;
|
||||
} else {
|
||||
nextDayId = null;
|
||||
}
|
||||
|
||||
let nextEndDayId: number | null;
|
||||
|
||||
@@ -10,6 +10,9 @@ export const DEFAULTABLE_USER_SETTING_KEYS = [
|
||||
'temperature_unit',
|
||||
'dark_mode',
|
||||
'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',
|
||||
'map_tile_url',
|
||||
// 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 { listBudgetItems } from './budgetService';
|
||||
import { listItems as listPackingItems } from './packingService';
|
||||
import { listReservations } from './reservationService';
|
||||
import { listReservations, loadEndpointsByTrip } from './reservationService';
|
||||
import { listNotes as listCollabNotes } from './collabService';
|
||||
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
|
||||
for (const r of reservations) {
|
||||
if (!r.reservation_time) continue;
|
||||
// Skip time-only values (no calendar date — occurs on relative "Day N" trips)
|
||||
const hasDate = r.reservation_time.includes('T')
|
||||
? /^\d{4}-\d{2}-\d{2}$/.test(r.reservation_time.split('T')[0])
|
||||
: /^\d{4}-\d{2}-\d{2}$/.test(r.reservation_time);
|
||||
if (!hasDate) continue;
|
||||
const hasTime = r.reservation_time.includes('T');
|
||||
const timeLines = buildReservationTimeLines(r);
|
||||
if (!timeLines) continue;
|
||||
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`;
|
||||
if (hasTime) {
|
||||
ics += `DTSTART:${fmtDateTime(r.reservation_time)}\r\n`;
|
||||
if (r.reservation_end_time) {
|
||||
const endDt = fmtDateTime(r.reservation_end_time, r.reservation_time);
|
||||
if (endDt.length >= 15) ics += `DTEND:${endDt}\r\n`;
|
||||
}
|
||||
} else {
|
||||
ics += `DTSTART;VALUE=DATE:${fmtDate(r.reservation_time)}\r\n`;
|
||||
}
|
||||
ics += timeLines;
|
||||
ics += `SUMMARY:${esc(r.title)}\r\n`;
|
||||
|
||||
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.
|
||||
const stops = [meta.legs[0]?.from, ...meta.legs.map((l: { to?: string }) => l.to)].filter(Boolean);
|
||||
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.arrival_airport) desc += `\nTo: ${meta.arrival_airport}`;
|
||||
} else {
|
||||
// Endpoint-based transport without route metadata: derive it from endpoints.
|
||||
const eps = endpointsMap.get(r.id);
|
||||
if (eps && eps.length > 1) {
|
||||
const stops = [...eps].sort((a, b) => a.sequence - b.sequence).map(e => e.code || e.name).filter(Boolean);
|
||||
if (stops.length > 1) desc += `\nRoute: ${stops.join(' → ')}`;
|
||||
}
|
||||
}
|
||||
if (meta.train_number) desc += `\nTrain: ${meta.train_number}`;
|
||||
if (r.notes) desc += `\n${r.notes}`;
|
||||
|
||||
@@ -31,6 +31,7 @@ const { svc } = vi.hoisted(() => ({
|
||||
verifyTripAccess: vi.fn(), listBudgetItems: vi.fn(), createBudgetItem: vi.fn(), updateBudgetItem: vi.fn(),
|
||||
deleteBudgetItem: vi.fn(), updateMembers: vi.fn(), toggleMemberPaid: vi.fn(), getPerPersonSummary: 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);
|
||||
@@ -104,4 +105,18 @@ describe('Budget e2e (real auth guard + temp SQLite)', () => {
|
||||
expect(res.status).toBe(400);
|
||||
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');
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
@@ -128,10 +128,12 @@ describe('Tool: mark_region_visited', () => {
|
||||
arguments: { regionCode: 'US-CA', regionName: 'California', countryCode: 'US' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
// Echoed in the client-facing shape ({ code, name, ... }), not raw DB columns.
|
||||
expect(data.region).toBeDefined();
|
||||
expect(data.region.region_code).toBe('US-CA');
|
||||
expect(data.region.region_name).toBe('California');
|
||||
expect(data.region.code).toBe('US-CA');
|
||||
expect(data.region.name).toBe('California');
|
||||
expect(data.region.country_code).toBe('US');
|
||||
expect(data.region.manuallyMarked).toBe(true);
|
||||
const row = testDb.prepare('SELECT * FROM visited_regions WHERE user_id = ? AND region_code = ?').get(user.id, 'US-CA');
|
||||
expect(row).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -37,7 +37,7 @@ vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip, createBudgetItem } from '../../helpers/factories';
|
||||
import { createUser, createTrip, createBudgetItem, addTripMember } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -70,7 +70,7 @@ async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promis
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: set_budget_item_members', () => {
|
||||
it('sets members and broadcasts budget:members-updated', async () => {
|
||||
it('sets members and returns a hydrated item with members/payers', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
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] },
|
||||
});
|
||||
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));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an error for an item not in the trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'set_budget_item_members',
|
||||
arguments: { tripId: trip.id, itemId: 99999, userIds: [user.id] },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('empty array clears members', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -35,7 +35,7 @@ vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip, createBudgetItem } from '../../helpers/factories';
|
||||
import { createUser, createTrip, createBudgetItem, addTripMember } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
|
||||
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 () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
|
||||
@@ -168,12 +168,14 @@ describe('Tool: create_accommodation', () => {
|
||||
start_day_id: day1.id,
|
||||
end_day_id: day2.id,
|
||||
check_in: '15:00',
|
||||
check_in_end: '20:00',
|
||||
check_out: '11:00',
|
||||
confirmation: 'CONF123',
|
||||
},
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.accommodation).toBeDefined();
|
||||
expect(data.accommodation.check_in_end).toBe('20:00');
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:created', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 { runMigrations } from '../../../src/db/migrations';
|
||||
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';
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -148,6 +148,8 @@ describe('Tool: create_packing_bag', () => {
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.bag).toBeDefined();
|
||||
expect(data.bag.name).toBe('Checked bag');
|
||||
// hydrated to match listBags/schema, which always carry a members array
|
||||
expect(data.bag.members).toEqual([]);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:bag-created', expect.any(Object));
|
||||
});
|
||||
});
|
||||
@@ -267,8 +269,9 @@ describe('Tool: set_bag_members', () => {
|
||||
arguments: { tripId: trip.id, bagId, userIds: [user.id] },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:bag-members-updated', expect.any(Object));
|
||||
// Returns the hydrated members list (REST parity), not { success }.
|
||||
expect(data.members.map((m: any) => m.user_id)).toEqual([user.id]);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:bag-members-updated', expect.objectContaining({ members: expect.any(Array) }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -284,7 +287,7 @@ describe('Tool: set_bag_members', () => {
|
||||
arguments: { tripId: trip.id, bagId, userIds: [] },
|
||||
});
|
||||
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] },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:assignees', expect.any(Object));
|
||||
// Returns the hydrated assignees list (REST parity), not { success }.
|
||||
expect(data.assignees.map((a: any) => a.user_id)).toEqual([user.id]);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:assignees', expect.objectContaining({ category: 'Clothing', assignees: expect.any(Array) }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -337,7 +341,7 @@ describe('Tool: set_packing_category_assignees', () => {
|
||||
arguments: { tripId: trip.id, categoryName: 'Clothing', userIds: [] },
|
||||
});
|
||||
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', () => {
|
||||
it('saves the current packing list as a template', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
it('saves the current packing list as a template for an admin', async () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createPackingItem(testDb, trip.id, { name: 'Toothbrush', category: 'Toiletries' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
@@ -388,7 +392,36 @@ describe('Tool: save_packing_template', () => {
|
||||
arguments: { tripId: trip.id, templateName: 'Weekend Trip' },
|
||||
});
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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 trip = createTrip(testDb, user.id);
|
||||
const items = [
|
||||
@@ -425,9 +542,33 @@ describe('Tool: bulk_import_packing', () => {
|
||||
arguments: { tripId: trip.id, items },
|
||||
});
|
||||
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(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;
|
||||
expect(data.reservation.accommodation_id).not.toBeNull();
|
||||
expect(data.accommodation_id).not.toBeNull();
|
||||
// accommodation_id must be a clean integer, not a stringified float ("14.0").
|
||||
expect(typeof data.reservation.accommodation_id).toBe('number');
|
||||
expect(Number.isInteger(data.reservation.accommodation_id)).toBe(true);
|
||||
expect(Number.isInteger(data.accommodation_id)).toBe(true);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:created', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
return {
|
||||
...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' }] }),
|
||||
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', () => {
|
||||
it('calls updatePlan and returns success', async () => {
|
||||
it('calls updatePlan and returns the hydrated plan', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
@@ -114,7 +116,11 @@ describe('Tool: update_vacay_plan', () => {
|
||||
arguments: { block_weekends: true, holidays_enabled: false },
|
||||
});
|
||||
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 () => {
|
||||
const { user } = createUser(testDb);
|
||||
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;
|
||||
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(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 /', () => {
|
||||
|
||||
@@ -75,13 +75,28 @@ describe('ReservationsService', () => {
|
||||
});
|
||||
|
||||
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 });
|
||||
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(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', () => {
|
||||
dbMock._stmt.get.mockReturnValueOnce({ id: 7 }); // existing lookup
|
||||
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' }),
|
||||
date: '2021-09-01',
|
||||
datePrecision: 'day',
|
||||
departure: '2021-09-01T23:00:00.000+00:00', // 19:00 local at JFK (EDT, UTC-4)
|
||||
arrival: '2021-09-02T07:00:00.000+00:00', // 08:00 local at LHR (BST, UTC+1)
|
||||
// Actual times (delayed) — TREK must IGNORE these and read the scheduled times.
|
||||
departure: '2021-09-01T23:42:00.000+00:00',
|
||||
arrival: '2021-09-02T07:42:00.000+00:00',
|
||||
departureScheduled: '2021-09-01T23:00:00.000+00:00', // 19:00 local at JFK (EDT, UTC-4)
|
||||
arrivalScheduled: '2021-09-02T07:00:00.000+00:00', // 08:00 local at LHR (BST, UTC+1)
|
||||
airline: { id: 1, icao: 'BAW', iata: 'BA', name: 'British Airways' },
|
||||
flightNumber: 'BA178',
|
||||
aircraft: { id: 1, icao: 'B772', name: 'Boeing 777' },
|
||||
@@ -48,6 +51,9 @@ describe('airtrailMapper.normalizeFlight', () => {
|
||||
flightNumber: 'BA178',
|
||||
seatClass: 'economy',
|
||||
});
|
||||
// The picker preview surfaces the scheduled times, not the actual ones.
|
||||
expect(n.departure).toBe('2021-09-01T23:00:00.000+00:00');
|
||||
expect(n.arrival).toBe('2021-09-02T07:00:00.000+00:00');
|
||||
});
|
||||
|
||||
it('falls back to ICAO when IATA is missing and tolerates null airports', () => {
|
||||
@@ -59,14 +65,24 @@ describe('airtrailMapper.normalizeFlight', () => {
|
||||
});
|
||||
|
||||
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());
|
||||
// 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');
|
||||
// 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');
|
||||
});
|
||||
|
||||
it('leaves the clock blank (date only) when the flight has no scheduled time', () => {
|
||||
const m = mapFlightToReservation(flight({ departureScheduled: null, arrivalScheduled: null }));
|
||||
// Date is preserved from the AirTrail canonical date; no fabricated 00:00.
|
||||
expect(m.reservation_time).toBe('2021-09-01');
|
||||
expect(m.reservation_end_time).toBeNull();
|
||||
expect(m.endpoints.find(e => e.role === 'from')?.local_time).toBeNull();
|
||||
expect(m.endpoints.find(e => e.role === 'to')?.local_time).toBeNull();
|
||||
});
|
||||
|
||||
it('builds two endpoints with codes, coords and timezones', () => {
|
||||
const m = mapFlightToReservation(flight());
|
||||
expect(m.endpoints).toHaveLength(2);
|
||||
@@ -88,6 +104,26 @@ describe('airtrailMapper.mapFlightToReservation', () => {
|
||||
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', () => {
|
||||
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();
|
||||
});
|
||||
|
||||
it('leaves the end time null for a partial flight with no arrival', () => {
|
||||
const m = mapFlightToReservation(flight({ arrival: null }));
|
||||
it('leaves the end time null for a partial flight with no scheduled arrival', () => {
|
||||
const m = mapFlightToReservation(flight({ arrivalScheduled: null }));
|
||||
expect(m.reservation_end_time).toBeNull();
|
||||
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' })));
|
||||
});
|
||||
|
||||
it('tracks the scheduled time and ignores actual-time changes', () => {
|
||||
// A scheduled-time change is what TREK imports, so it must re-sync...
|
||||
expect(canonicalHash(flight())).not.toBe(
|
||||
canonicalHash(flight({ departureScheduled: '2021-09-01T22:00:00.000+00:00' })),
|
||||
);
|
||||
// ...but a change to the actual time alone must not (TREK never shows it).
|
||||
expect(canonicalHash(flight())).toBe(
|
||||
canonicalHash(flight({ departure: '2021-09-01T20:00:00.000+00:00', arrival: '2021-09-02T05:00:00.000+00:00' })),
|
||||
);
|
||||
});
|
||||
|
||||
it('is independent of seat ordering', () => {
|
||||
const a = flight({
|
||||
seats: [
|
||||
|
||||
@@ -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);
|
||||
|
||||
import { calculateSettlement } from '../../../src/services/budgetService';
|
||||
import { calculateSettlement, updateSettlement } from '../../../src/services/budgetService';
|
||||
import type { BudgetItem, BudgetItemMember, BudgetItemPayer } from '../../../src/types';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
@@ -189,4 +189,60 @@ describe('calculateSettlement', () => {
|
||||
expect(result.flows).toHaveLength(1);
|
||||
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');
|
||||
});
|
||||
|
||||
it('MAPS-CID-001: resolves a cid= URL by following the redirect to a coordinate URL', async () => {
|
||||
// cid URLs (what get_place_details returns, and Google "Share" links) carry no
|
||||
// inline coords; the redirect target carries the !3d!4d data param.
|
||||
const fetchMock = vi.fn(async (u: string) => {
|
||||
if (u.includes('nominatim')) {
|
||||
return { ok: true, json: async () => ({ display_name: 'Paris, France', name: 'Eiffel Tower', address: {} }) };
|
||||
}
|
||||
return { url: 'https://www.google.com/maps/place/Eiffel+Tower/data=!3d48.8584!4d2.2945', text: async () => '' };
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService');
|
||||
const result = await resolveGoogleMapsUrl('https://maps.google.com/?cid=1234567890');
|
||||
expect(result.lat).toBeCloseTo(48.8584, 3);
|
||||
expect(result.lng).toBeCloseTo(2.2945, 3);
|
||||
});
|
||||
|
||||
it('MAPS-CID-002: falls back to parsing coordinates from the page body', async () => {
|
||||
const fetchMock = vi.fn(async (u: string) => {
|
||||
if (u.includes('nominatim')) {
|
||||
return { ok: true, json: async () => ({ display_name: 'NYC, USA', name: null, address: {} }) };
|
||||
}
|
||||
if (u.includes('cid=')) {
|
||||
// Redirect target has no inline coords.
|
||||
return { url: 'https://www.google.com/maps/place/Somewhere', text: async () => '' };
|
||||
}
|
||||
// Body fetch of the resolved URL embeds coords in the map data.
|
||||
return { url: 'https://www.google.com/maps/place/Somewhere', text: async () => 'x!3d40.6892!4d-74.0445y' };
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService');
|
||||
const result = await resolveGoogleMapsUrl('https://www.google.com/maps?cid=999');
|
||||
expect(result.lat).toBeCloseTo(40.6892, 3);
|
||||
expect(result.lng).toBeCloseTo(-74.0445, 3);
|
||||
});
|
||||
|
||||
it('MAPS-024 (ReDoS): /@(-?\\d+\\.?\\d*),(-?\\d+\\.?\\d*)/ on adversarial input < 500ms', () => {
|
||||
const adversarial = '/@' + '1'.repeat(10000) + '.';
|
||||
const start = Date.now();
|
||||
|
||||
@@ -397,6 +397,46 @@ describe('exportICS', () => {
|
||||
|
||||
expect(ics).toContain('DTEND:20250602T160000');
|
||||
});
|
||||
|
||||
it('TRIP-SVC-010: flight with endpoint times but no reservation_time is included', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Paris Trip' });
|
||||
const reservation = createReservation(testDb, trip.id, {
|
||||
title: 'CDG → JFK',
|
||||
type: 'flight',
|
||||
});
|
||||
// Confirmed flights store times per endpoint, never as reservation_time.
|
||||
testDb.prepare('UPDATE reservations SET reservation_time=NULL, reservation_end_time=NULL WHERE id=?').run(reservation.id);
|
||||
const insertEp = testDb.prepare(
|
||||
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
insertEp.run(reservation.id, 'from', 0, 'Paris CDG', 'CDG', 49.0, 2.5, 'Europe/Paris', '09:00', '2025-06-02');
|
||||
insertEp.run(reservation.id, 'to', 1, 'New York JFK', 'JFK', 40.6, -73.8, 'America/New_York', '12:00', '2025-06-02');
|
||||
|
||||
const { ics } = exportICS(trip.id);
|
||||
|
||||
expect(ics).toContain('SUMMARY:CDG → JFK');
|
||||
expect(ics).toContain('DTSTART:20250602T090000');
|
||||
expect(ics).toContain('DTEND:20250602T120000');
|
||||
expect(ics).toContain('Route: CDG → JFK');
|
||||
});
|
||||
|
||||
it('TRIP-SVC-011: flight endpoint with no local_date is skipped (relative Day-N trips)', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Relative Trip' });
|
||||
const reservation = createReservation(testDb, trip.id, {
|
||||
title: 'Timeless Flight',
|
||||
type: 'flight',
|
||||
});
|
||||
testDb.prepare('UPDATE reservations SET reservation_time=NULL WHERE id=?').run(reservation.id);
|
||||
testDb.prepare(
|
||||
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(reservation.id, 'from', 0, 'Origin', 'AAA', 1.0, 1.0, null, '09:00', null);
|
||||
|
||||
const { ics } = exportICS(trip.id);
|
||||
|
||||
expect(ics).not.toContain('SUMMARY:Timeless Flight');
|
||||
});
|
||||
});
|
||||
|
||||
// ── deleteOldCover — path containment ──────────────────────────────────────────
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"plugins": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/shared",
|
||||
"version": "3.1.0",
|
||||
"version": "3.1.1",
|
||||
"private": true,
|
||||
"description": "Shared API contracts (Zod schemas) — single source of truth for TREK server and client.",
|
||||
"type": "module",
|
||||
|
||||
@@ -16,16 +16,9 @@ describe('adminUserCreateRequestSchema', () => {
|
||||
role: 'admin',
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
adminUserCreateRequestSchema.safeParse({ email: 'a@b.c' }).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
adminUserCreateRequestSchema.safeParse({ password: 'p' }).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
adminUserCreateRequestSchema.safeParse({ email: 'a@b.c', role: 'root' })
|
||||
.success,
|
||||
).toBe(false);
|
||||
expect(adminUserCreateRequestSchema.safeParse({ email: 'a@b.c' }).success).toBe(true);
|
||||
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,
|
||||
).toBe(true);
|
||||
expect(adminInviteCreateRequestSchema.safeParse({}).success).toBe(true);
|
||||
expect(
|
||||
adminInviteCreateRequestSchema.safeParse({ role: 'root' }).success,
|
||||
).toBe(false);
|
||||
expect(adminInviteCreateRequestSchema.safeParse({ role: 'root' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('adminFeatureToggleRequestSchema', () => {
|
||||
it('requires a boolean enabled', () => {
|
||||
expect(
|
||||
adminFeatureToggleRequestSchema.safeParse({ enabled: true }).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
adminFeatureToggleRequestSchema.safeParse({ enabled: 'yes' }).success,
|
||||
).toBe(false);
|
||||
expect(adminFeatureToggleRequestSchema.safeParse({ enabled: true }).success).toBe(true);
|
||||
expect(adminFeatureToggleRequestSchema.safeParse({ enabled: 'yes' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,29 +14,21 @@ export const adminUserCreateRequestSchema = z.object({
|
||||
username: z.string().optional(),
|
||||
role: z.enum(['user', 'admin']).optional(),
|
||||
});
|
||||
export type AdminUserCreateRequest = z.infer<
|
||||
typeof adminUserCreateRequestSchema
|
||||
>;
|
||||
export type AdminUserCreateRequest = z.infer<typeof adminUserCreateRequestSchema>;
|
||||
|
||||
export const adminPermissionsRequestSchema = z.object({
|
||||
permissions: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
export type AdminPermissionsRequest = z.infer<
|
||||
typeof adminPermissionsRequestSchema
|
||||
>;
|
||||
export type AdminPermissionsRequest = z.infer<typeof adminPermissionsRequestSchema>;
|
||||
|
||||
export const adminInviteCreateRequestSchema = z.object({
|
||||
max_uses: z.number().optional(),
|
||||
expires_in_days: z.number().optional(),
|
||||
role: z.enum(['user', 'admin']).optional(),
|
||||
});
|
||||
export type AdminInviteCreateRequest = z.infer<
|
||||
typeof adminInviteCreateRequestSchema
|
||||
>;
|
||||
export type AdminInviteCreateRequest = z.infer<typeof adminInviteCreateRequestSchema>;
|
||||
|
||||
export const adminFeatureToggleRequestSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
export type AdminFeatureToggleRequest = z.infer<
|
||||
typeof adminFeatureToggleRequestSchema
|
||||
>;
|
||||
export type AdminFeatureToggleRequest = z.infer<typeof adminFeatureToggleRequestSchema>;
|
||||
|
||||
@@ -21,6 +21,11 @@ export const airtrailSettingsSchema = z.object({
|
||||
apiKey: z.string().max(512).optional(),
|
||||
/** Allow self-signed TLS certs (common on LAN instances). */
|
||||
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>;
|
||||
|
||||
@@ -28,6 +33,7 @@ export const airtrailConnectionSchema = z.object({
|
||||
url: z.string(),
|
||||
apiKeyMasked: z.string(),
|
||||
allowInsecureTls: z.boolean(),
|
||||
writeEnabled: z.boolean(),
|
||||
connected: z.boolean(),
|
||||
});
|
||||
export type AirtrailConnection = z.infer<typeof airtrailConnectionSchema>;
|
||||
|
||||
@@ -8,38 +8,23 @@ import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('assignmentCreateRequestSchema', () => {
|
||||
it('requires a place_id; notes optional/nullable', () => {
|
||||
expect(
|
||||
assignmentCreateRequestSchema.safeParse({ place_id: 2 }).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
assignmentCreateRequestSchema.safeParse({ place_id: '2', notes: null })
|
||||
.success,
|
||||
).toBe(true);
|
||||
expect(assignmentCreateRequestSchema.safeParse({ place_id: 2 }).success).toBe(true);
|
||||
expect(assignmentCreateRequestSchema.safeParse({ place_id: '2', notes: null }).success).toBe(true);
|
||||
expect(assignmentCreateRequestSchema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assignmentMoveRequestSchema', () => {
|
||||
it('requires new_day_id; order_index optional', () => {
|
||||
expect(
|
||||
assignmentMoveRequestSchema.safeParse({ new_day_id: 4 }).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
assignmentMoveRequestSchema.safeParse({ new_day_id: 4, order_index: 0 })
|
||||
.success,
|
||||
).toBe(true);
|
||||
expect(assignmentMoveRequestSchema.safeParse({ new_day_id: 4 }).success).toBe(true);
|
||||
expect(assignmentMoveRequestSchema.safeParse({ new_day_id: 4, order_index: 0 }).success).toBe(true);
|
||||
expect(assignmentMoveRequestSchema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assignmentParticipantsRequestSchema', () => {
|
||||
it('requires a numeric user_ids array', () => {
|
||||
expect(
|
||||
assignmentParticipantsRequestSchema.safeParse({ user_ids: [1, 2] })
|
||||
.success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
assignmentParticipantsRequestSchema.safeParse({ user_ids: 'no' }).success,
|
||||
).toBe(false);
|
||||
expect(assignmentParticipantsRequestSchema.safeParse({ user_ids: [1, 2] }).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()]),
|
||||
notes: z.string().nullable().optional(),
|
||||
});
|
||||
export type AssignmentCreateRequest = z.infer<
|
||||
typeof assignmentCreateRequestSchema
|
||||
>;
|
||||
export type AssignmentCreateRequest = z.infer<typeof assignmentCreateRequestSchema>;
|
||||
|
||||
export const assignmentReorderRequestSchema = z.object({
|
||||
orderedIds: z.array(z.number()),
|
||||
});
|
||||
export type AssignmentReorderRequest = z.infer<
|
||||
typeof assignmentReorderRequestSchema
|
||||
>;
|
||||
export type AssignmentReorderRequest = z.infer<typeof assignmentReorderRequestSchema>;
|
||||
|
||||
export const assignmentMoveRequestSchema = z.object({
|
||||
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({
|
||||
user_ids: z.array(z.number()),
|
||||
});
|
||||
export type AssignmentParticipantsRequest = z.infer<
|
||||
typeof assignmentParticipantsRequestSchema
|
||||
>;
|
||||
export type AssignmentParticipantsRequest = z.infer<typeof assignmentParticipantsRequestSchema>;
|
||||
|
||||
@@ -1,28 +1,17 @@
|
||||
import {
|
||||
markRegionRequestSchema,
|
||||
createBucketItemRequestSchema,
|
||||
regionGeoSchema,
|
||||
} from './atlas.schema';
|
||||
import { markRegionRequestSchema, createBucketItemRequestSchema, regionGeoSchema } from './atlas.schema';
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('markRegionRequestSchema', () => {
|
||||
it('requires both name and country_code', () => {
|
||||
expect(
|
||||
markRegionRequestSchema.safeParse({ name: 'Bavaria', country_code: 'DE' })
|
||||
.success,
|
||||
).toBe(true);
|
||||
expect(markRegionRequestSchema.safeParse({ name: 'Bavaria' }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(markRegionRequestSchema.safeParse({ name: 'Bavaria', country_code: 'DE' }).success).toBe(true);
|
||||
expect(markRegionRequestSchema.safeParse({ name: 'Bavaria' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createBucketItemRequestSchema', () => {
|
||||
it('requires a name; coordinates and metadata optional/nullable', () => {
|
||||
expect(
|
||||
createBucketItemRequestSchema.safeParse({ name: 'Tokyo' }).success,
|
||||
).toBe(true);
|
||||
expect(createBucketItemRequestSchema.safeParse({ name: 'Tokyo' }).success).toBe(true);
|
||||
expect(
|
||||
createBucketItemRequestSchema.safeParse({
|
||||
name: 'Tokyo',
|
||||
@@ -37,18 +26,13 @@ describe('createBucketItemRequestSchema', () => {
|
||||
|
||||
describe('regionGeoSchema', () => {
|
||||
it('accepts a FeatureCollection with opaque features', () => {
|
||||
expect(
|
||||
regionGeoSchema.safeParse({ type: 'FeatureCollection', features: [] })
|
||||
.success,
|
||||
).toBe(true);
|
||||
expect(regionGeoSchema.safeParse({ type: 'FeatureCollection', features: [] }).success).toBe(true);
|
||||
expect(
|
||||
regionGeoSchema.safeParse({
|
||||
type: 'FeatureCollection',
|
||||
features: [{ anything: true }],
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
regionGeoSchema.safeParse({ type: 'Other', features: [] }).success,
|
||||
).toBe(false);
|
||||
expect(regionGeoSchema.safeParse({ type: 'Other', features: [] }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,9 +29,7 @@ export const createBucketItemRequestSchema = z.object({
|
||||
notes: z.string().nullable().optional(),
|
||||
target_date: z.string().nullable().optional(),
|
||||
});
|
||||
export type CreateBucketItemRequest = z.infer<
|
||||
typeof createBucketItemRequestSchema
|
||||
>;
|
||||
export type CreateBucketItemRequest = z.infer<typeof createBucketItemRequestSchema>;
|
||||
|
||||
export const updateBucketItemRequestSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
@@ -41,9 +39,7 @@ export const updateBucketItemRequestSchema = z.object({
|
||||
country_code: z.string().nullable().optional(),
|
||||
target_date: z.string().nullable().optional(),
|
||||
});
|
||||
export type UpdateBucketItemRequest = z.infer<
|
||||
typeof updateBucketItemRequestSchema
|
||||
>;
|
||||
export type UpdateBucketItemRequest = z.infer<typeof updateBucketItemRequestSchema>;
|
||||
|
||||
/** A bucket-list item row (DB-shaped; kept open). */
|
||||
export const bucketItemSchema = open;
|
||||
@@ -59,3 +55,178 @@ export const regionGeoSchema = z.object({
|
||||
features: z.array(z.unknown()),
|
||||
});
|
||||
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', () => {
|
||||
it('requires email + password; username/invite optional', () => {
|
||||
expect(
|
||||
registerRequestSchema.safeParse({ email: 'a@b.c', password: 'pw' })
|
||||
.success,
|
||||
).toBe(true);
|
||||
expect(registerRequestSchema.safeParse({ email: 'a@b.c', password: 'pw' }).success).toBe(true);
|
||||
expect(
|
||||
registerRequestSchema.safeParse({
|
||||
email: 'a@b.c',
|
||||
@@ -24,32 +21,21 @@ describe('registerRequestSchema', () => {
|
||||
invite_token: 't',
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(registerRequestSchema.safeParse({ email: 'a@b.c' }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(registerRequestSchema.safeParse({ email: 'a@b.c' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loginRequestSchema', () => {
|
||||
it('requires email + password', () => {
|
||||
expect(
|
||||
loginRequestSchema.safeParse({ email: 'a@b.c', password: 'pw' }).success,
|
||||
).toBe(true);
|
||||
expect(loginRequestSchema.safeParse({ email: 'a@b.c' }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(loginRequestSchema.safeParse({ email: 'a@b.c', password: 'pw' }).success).toBe(true);
|
||||
expect(loginRequestSchema.safeParse({ email: 'a@b.c' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('forgot/reset/change password schemas', () => {
|
||||
it('validate their required fields', () => {
|
||||
expect(
|
||||
forgotPasswordRequestSchema.safeParse({ email: 'a@b.c' }).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
resetPasswordRequestSchema.safeParse({ token: 't', new_password: 'pw' })
|
||||
.success,
|
||||
).toBe(true);
|
||||
expect(forgotPasswordRequestSchema.safeParse({ email: 'a@b.c' }).success).toBe(true);
|
||||
expect(resetPasswordRequestSchema.safeParse({ token: 't', new_password: 'pw' }).success).toBe(true);
|
||||
expect(
|
||||
resetPasswordRequestSchema.safeParse({
|
||||
token: 't',
|
||||
@@ -57,36 +43,23 @@ describe('forgot/reset/change password schemas', () => {
|
||||
mfa_code: '123456',
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
resetPasswordRequestSchema.safeParse({ new_password: 'pw' }).success,
|
||||
).toBe(false);
|
||||
expect(resetPasswordRequestSchema.safeParse({ new_password: 'pw' }).success).toBe(false);
|
||||
expect(
|
||||
changePasswordRequestSchema.safeParse({
|
||||
current_password: 'a',
|
||||
new_password: 'b',
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
changePasswordRequestSchema.safeParse({ new_password: 'b' }).success,
|
||||
).toBe(false);
|
||||
expect(changePasswordRequestSchema.safeParse({ new_password: 'b' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfa + mcp-token schemas', () => {
|
||||
it('validate their fields', () => {
|
||||
expect(
|
||||
mfaVerifyLoginRequestSchema.safeParse({ mfa_token: 't', code: '123456' })
|
||||
.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(mfaVerifyLoginRequestSchema.safeParse({ mfa_token: 't', code: '123456' }).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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,16 +11,11 @@ describe('autoBackupSettingsRequestSchema', () => {
|
||||
keep_days: 7,
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
autoBackupSettingsRequestSchema.safeParse({ enabled: false, foo: 'bar' })
|
||||
.success,
|
||||
).toBe(true);
|
||||
expect(autoBackupSettingsRequestSchema.safeParse({ enabled: false, foo: 'bar' }).success).toBe(true);
|
||||
expect(autoBackupSettingsRequestSchema.safeParse({}).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a non-boolean enabled', () => {
|
||||
expect(
|
||||
autoBackupSettingsRequestSchema.safeParse({ enabled: 'yes' }).success,
|
||||
).toBe(false);
|
||||
expect(autoBackupSettingsRequestSchema.safeParse({ enabled: 'yes' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,4 @@ export const autoBackupSettingsRequestSchema = z
|
||||
time: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
export type AutoBackupSettingsRequest = z.infer<
|
||||
typeof autoBackupSettingsRequestSchema
|
||||
>;
|
||||
export type AutoBackupSettingsRequest = z.infer<typeof autoBackupSettingsRequestSchema>;
|
||||
|
||||
@@ -9,9 +9,7 @@ import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('budgetCreateItemRequestSchema', () => {
|
||||
it('requires a name; money/meta fields optional + nullable', () => {
|
||||
expect(
|
||||
budgetCreateItemRequestSchema.safeParse({ name: 'Hotel' }).success,
|
||||
).toBe(true);
|
||||
expect(budgetCreateItemRequestSchema.safeParse({ name: 'Hotel' }).success).toBe(true);
|
||||
expect(
|
||||
budgetCreateItemRequestSchema.safeParse({
|
||||
name: 'Hotel',
|
||||
@@ -25,34 +23,21 @@ describe('budgetCreateItemRequestSchema', () => {
|
||||
|
||||
describe('budgetUpdateMembersRequestSchema', () => {
|
||||
it('requires a numeric user_ids array', () => {
|
||||
expect(
|
||||
budgetUpdateMembersRequestSchema.safeParse({ user_ids: [1, 2] }).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
budgetUpdateMembersRequestSchema.safeParse({ user_ids: 'no' }).success,
|
||||
).toBe(false);
|
||||
expect(budgetUpdateMembersRequestSchema.safeParse({ user_ids: [1, 2] }).success).toBe(true);
|
||||
expect(budgetUpdateMembersRequestSchema.safeParse({ user_ids: 'no' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('budgetToggleMemberPaidRequestSchema', () => {
|
||||
it('requires a boolean paid', () => {
|
||||
expect(
|
||||
budgetToggleMemberPaidRequestSchema.safeParse({ paid: true }).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
budgetToggleMemberPaidRequestSchema.safeParse({ paid: 'yes' }).success,
|
||||
).toBe(false);
|
||||
expect(budgetToggleMemberPaidRequestSchema.safeParse({ paid: true }).success).toBe(true);
|
||||
expect(budgetToggleMemberPaidRequestSchema.safeParse({ paid: 'yes' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('budgetReorderItemsRequestSchema', () => {
|
||||
it('requires numeric ids', () => {
|
||||
expect(
|
||||
budgetReorderItemsRequestSchema.safeParse({ orderedIds: [3, 1, 2] })
|
||||
.success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
budgetReorderItemsRequestSchema.safeParse({ orderedIds: ['a'] }).success,
|
||||
).toBe(false);
|
||||
expect(budgetReorderItemsRequestSchema.safeParse({ orderedIds: [3, 1, 2] }).success).toBe(true);
|
||||
expect(budgetReorderItemsRequestSchema.safeParse({ orderedIds: ['a'] }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -145,9 +145,7 @@ export const budgetCreateItemRequestSchema = z.object({
|
||||
// "add expense" flow). The server stores it on budget_items.reservation_id.
|
||||
reservation_id: z.number().optional(),
|
||||
});
|
||||
export type BudgetCreateItemRequest = z.infer<
|
||||
typeof budgetCreateItemRequestSchema
|
||||
>;
|
||||
export type BudgetCreateItemRequest = z.infer<typeof budgetCreateItemRequestSchema>;
|
||||
|
||||
/** Update accepts the same fields plus total_price changes; all optional. */
|
||||
export const budgetUpdateItemRequestSchema = z.object({
|
||||
@@ -163,17 +161,13 @@ export const budgetUpdateItemRequestSchema = z.object({
|
||||
note: z.string().nullable().optional(),
|
||||
expense_date: z.string().nullable().optional(),
|
||||
});
|
||||
export type BudgetUpdateItemRequest = z.infer<
|
||||
typeof budgetUpdateItemRequestSchema
|
||||
>;
|
||||
export type BudgetUpdateItemRequest = z.infer<typeof budgetUpdateItemRequestSchema>;
|
||||
|
||||
/** Replace the explicit payers of an expense (amounts in expense currency). */
|
||||
export const budgetUpdatePayersRequestSchema = z.object({
|
||||
payers: z.array(payerInputSchema),
|
||||
});
|
||||
export type BudgetUpdatePayersRequest = z.infer<
|
||||
typeof budgetUpdatePayersRequestSchema
|
||||
>;
|
||||
export type BudgetUpdatePayersRequest = z.infer<typeof budgetUpdatePayersRequestSchema>;
|
||||
|
||||
/**
|
||||
* A persisted settle-up transfer (budget_settlements row): "from paid to" a
|
||||
@@ -200,34 +194,32 @@ export const budgetCreateSettlementRequestSchema = z.object({
|
||||
to_user_id: z.number(),
|
||||
amount: z.number(),
|
||||
});
|
||||
export type BudgetCreateSettlementRequest = z.infer<
|
||||
typeof budgetCreateSettlementRequestSchema
|
||||
>;
|
||||
export type BudgetCreateSettlementRequest = z.infer<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({
|
||||
user_ids: z.array(z.number()),
|
||||
});
|
||||
export type BudgetUpdateMembersRequest = z.infer<
|
||||
typeof budgetUpdateMembersRequestSchema
|
||||
>;
|
||||
export type BudgetUpdateMembersRequest = z.infer<typeof budgetUpdateMembersRequestSchema>;
|
||||
|
||||
export const budgetToggleMemberPaidRequestSchema = z.object({
|
||||
paid: z.boolean(),
|
||||
});
|
||||
export type BudgetToggleMemberPaidRequest = z.infer<
|
||||
typeof budgetToggleMemberPaidRequestSchema
|
||||
>;
|
||||
export type BudgetToggleMemberPaidRequest = z.infer<typeof budgetToggleMemberPaidRequestSchema>;
|
||||
|
||||
export const budgetReorderItemsRequestSchema = z.object({
|
||||
orderedIds: z.array(z.number()),
|
||||
});
|
||||
export type BudgetReorderItemsRequest = z.infer<
|
||||
typeof budgetReorderItemsRequestSchema
|
||||
>;
|
||||
export type BudgetReorderItemsRequest = z.infer<typeof budgetReorderItemsRequestSchema>;
|
||||
|
||||
export const budgetReorderCategoriesRequestSchema = z.object({
|
||||
orderedCategories: z.array(z.string()),
|
||||
});
|
||||
export type BudgetReorderCategoriesRequest = z.infer<
|
||||
typeof budgetReorderCategoriesRequestSchema
|
||||
>;
|
||||
export type BudgetReorderCategoriesRequest = z.infer<typeof budgetReorderCategoriesRequestSchema>;
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
categorySchema,
|
||||
createCategoryRequestSchema,
|
||||
updateCategoryRequestSchema,
|
||||
} from './category.schema';
|
||||
import { categorySchema, createCategoryRequestSchema, updateCategoryRequestSchema } from './category.schema';
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
@@ -21,12 +17,8 @@ describe('categorySchema', () => {
|
||||
|
||||
describe('createCategoryRequestSchema', () => {
|
||||
it('requires a non-empty name; colour and icon are optional', () => {
|
||||
expect(
|
||||
createCategoryRequestSchema.safeParse({ name: 'Food' }).success,
|
||||
).toBe(true);
|
||||
expect(createCategoryRequestSchema.safeParse({ name: '' }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(createCategoryRequestSchema.safeParse({ name: 'Food' }).success).toBe(true);
|
||||
expect(createCategoryRequestSchema.safeParse({ name: '' }).success).toBe(false);
|
||||
expect(createCategoryRequestSchema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -34,8 +26,6 @@ describe('createCategoryRequestSchema', () => {
|
||||
describe('updateCategoryRequestSchema', () => {
|
||||
it('allows every field to be omitted (the service COALESCEs)', () => {
|
||||
expect(updateCategoryRequestSchema.safeParse({}).success).toBe(true);
|
||||
expect(
|
||||
updateCategoryRequestSchema.safeParse({ color: '#000' }).success,
|
||||
).toBe(true);
|
||||
expect(updateCategoryRequestSchema.safeParse({ color: '#000' }).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,12 +10,8 @@ import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('collabNoteCreateRequestSchema', () => {
|
||||
it('requires a non-empty title; the rest is optional', () => {
|
||||
expect(
|
||||
collabNoteCreateRequestSchema.safeParse({ title: 'Idea' }).success,
|
||||
).toBe(true);
|
||||
expect(collabNoteCreateRequestSchema.safeParse({ title: '' }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(collabNoteCreateRequestSchema.safeParse({ title: 'Idea' }).success).toBe(true);
|
||||
expect(collabNoteCreateRequestSchema.safeParse({ title: '' }).success).toBe(false);
|
||||
expect(collabNoteCreateRequestSchema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -34,50 +30,29 @@ describe('collabPollCreateRequestSchema', () => {
|
||||
options: ['A'],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
collabPollCreateRequestSchema.safeParse({ options: ['A', 'B'] }).success,
|
||||
).toBe(false);
|
||||
expect(collabPollCreateRequestSchema.safeParse({ options: ['A', 'B'] }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collabPollVoteRequestSchema', () => {
|
||||
it('requires a numeric option_index', () => {
|
||||
expect(
|
||||
collabPollVoteRequestSchema.safeParse({ option_index: 0 }).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
collabPollVoteRequestSchema.safeParse({ option_index: 'a' }).success,
|
||||
).toBe(false);
|
||||
expect(collabPollVoteRequestSchema.safeParse({ option_index: 0 }).success).toBe(true);
|
||||
expect(collabPollVoteRequestSchema.safeParse({ option_index: 'a' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collabMessageCreateRequestSchema', () => {
|
||||
it('requires text, caps it at 5000, allows a nullable reply_to', () => {
|
||||
expect(
|
||||
collabMessageCreateRequestSchema.safeParse({ text: 'hi', reply_to: null })
|
||||
.success,
|
||||
).toBe(true);
|
||||
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);
|
||||
expect(collabMessageCreateRequestSchema.safeParse({ text: 'hi', reply_to: null }).success).toBe(true);
|
||||
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', () => {
|
||||
it('requires a non-empty emoji', () => {
|
||||
expect(collabReactionRequestSchema.safeParse({ emoji: '👍' }).success).toBe(
|
||||
true,
|
||||
);
|
||||
expect(collabReactionRequestSchema.safeParse({ emoji: '' }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(collabReactionRequestSchema.safeParse({ emoji: '👍' }).success).toBe(true);
|
||||
expect(collabReactionRequestSchema.safeParse({ emoji: '' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,9 +18,7 @@ export const collabNoteCreateRequestSchema = z.object({
|
||||
color: z.string().optional(),
|
||||
website: z.string().optional(),
|
||||
});
|
||||
export type CollabNoteCreateRequest = z.infer<
|
||||
typeof collabNoteCreateRequestSchema
|
||||
>;
|
||||
export type CollabNoteCreateRequest = z.infer<typeof collabNoteCreateRequestSchema>;
|
||||
|
||||
export const collabNoteUpdateRequestSchema = z.object({
|
||||
title: z.string().optional(),
|
||||
@@ -30,9 +28,7 @@ export const collabNoteUpdateRequestSchema = z.object({
|
||||
pinned: z.union([z.boolean(), z.number()]).optional(),
|
||||
website: z.string().optional(),
|
||||
});
|
||||
export type CollabNoteUpdateRequest = z.infer<
|
||||
typeof collabNoteUpdateRequestSchema
|
||||
>;
|
||||
export type CollabNoteUpdateRequest = z.infer<typeof collabNoteUpdateRequestSchema>;
|
||||
|
||||
export const collabPollCreateRequestSchema = z.object({
|
||||
question: z.string().min(1),
|
||||
@@ -41,9 +37,7 @@ export const collabPollCreateRequestSchema = z.object({
|
||||
multiple_choice: z.boolean().optional(),
|
||||
deadline: z.string().optional(),
|
||||
});
|
||||
export type CollabPollCreateRequest = z.infer<
|
||||
typeof collabPollCreateRequestSchema
|
||||
>;
|
||||
export type CollabPollCreateRequest = z.infer<typeof collabPollCreateRequestSchema>;
|
||||
|
||||
export const collabPollVoteRequestSchema = z.object({
|
||||
option_index: z.number(),
|
||||
@@ -54,9 +48,7 @@ export const collabMessageCreateRequestSchema = z.object({
|
||||
text: z.string().min(1).max(5000),
|
||||
reply_to: z.number().nullable().optional(),
|
||||
});
|
||||
export type CollabMessageCreateRequest = z.infer<
|
||||
typeof collabMessageCreateRequestSchema
|
||||
>;
|
||||
export type CollabMessageCreateRequest = z.infer<typeof collabMessageCreateRequestSchema>;
|
||||
|
||||
export const collabReactionRequestSchema = z.object({
|
||||
emoji: z.string().min(1),
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { paginationQuerySchema } from './pagination.schema';
|
||||
import {
|
||||
idSchema,
|
||||
idParamSchema,
|
||||
nonEmptyString,
|
||||
isoDateTime,
|
||||
} from './primitives.schema';
|
||||
import { idSchema, idParamSchema, nonEmptyString, isoDateTime } from './primitives.schema';
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
@@ -43,8 +38,6 @@ describe('@trek/shared pagination', () => {
|
||||
|
||||
it('enforces bounds', () => {
|
||||
expect(paginationQuerySchema.safeParse({ perPage: 0 }).success).toBe(false);
|
||||
expect(paginationQuerySchema.safeParse({ perPage: 999 }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(paginationQuerySchema.safeParse({ perPage: 999 }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,32 +1,19 @@
|
||||
import {
|
||||
dayCreateRequestSchema,
|
||||
dayNoteCreateRequestSchema,
|
||||
dayNoteUpdateRequestSchema,
|
||||
} from './day.schema';
|
||||
import { dayCreateRequestSchema, dayNoteCreateRequestSchema, dayNoteUpdateRequestSchema } from './day.schema';
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('dayCreateRequestSchema', () => {
|
||||
it('accepts an optional date + notes', () => {
|
||||
expect(dayCreateRequestSchema.safeParse({}).success).toBe(true);
|
||||
expect(
|
||||
dayCreateRequestSchema.safeParse({ date: '2026-07-01', notes: 'n' })
|
||||
.success,
|
||||
).toBe(true);
|
||||
expect(dayCreateRequestSchema.safeParse({ date: '2026-07-01', notes: 'n' }).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dayNoteCreateRequestSchema', () => {
|
||||
it('requires non-empty text capped at 500, time capped at 250', () => {
|
||||
expect(
|
||||
dayNoteCreateRequestSchema.safeParse({ text: 'Lunch' }).success,
|
||||
).toBe(true);
|
||||
expect(dayNoteCreateRequestSchema.safeParse({ text: '' }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
dayNoteCreateRequestSchema.safeParse({ text: 'x'.repeat(501) }).success,
|
||||
).toBe(false);
|
||||
expect(dayNoteCreateRequestSchema.safeParse({ text: 'Lunch' }).success).toBe(true);
|
||||
expect(dayNoteCreateRequestSchema.safeParse({ text: '' }).success).toBe(false);
|
||||
expect(dayNoteCreateRequestSchema.safeParse({ text: 'x'.repeat(501) }).success).toBe(false);
|
||||
expect(
|
||||
dayNoteCreateRequestSchema.safeParse({
|
||||
text: 'ok',
|
||||
@@ -39,11 +26,7 @@ describe('dayNoteCreateRequestSchema', () => {
|
||||
describe('dayNoteUpdateRequestSchema', () => {
|
||||
it('allows omitting text and caps the lengths', () => {
|
||||
expect(dayNoteUpdateRequestSchema.safeParse({}).success).toBe(true);
|
||||
expect(dayNoteUpdateRequestSchema.safeParse({ icon: '🍽️' }).success).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
dayNoteUpdateRequestSchema.safeParse({ text: 'x'.repeat(501) }).success,
|
||||
).toBe(false);
|
||||
expect(dayNoteUpdateRequestSchema.safeParse({ icon: '🍽️' }).success).toBe(true);
|
||||
expect(dayNoteUpdateRequestSchema.safeParse({ text: 'x'.repeat(501) }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,34 +1,19 @@
|
||||
import {
|
||||
fileUpdateRequestSchema,
|
||||
fileLinkRequestSchema,
|
||||
photoVariantSchema,
|
||||
} from './file.schema';
|
||||
import { fileUpdateRequestSchema, fileLinkRequestSchema, photoVariantSchema } from './file.schema';
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('fileUpdateRequestSchema', () => {
|
||||
it('accepts optional metadata, nullable ids, an empty body', () => {
|
||||
expect(
|
||||
fileUpdateRequestSchema.safeParse({ description: 'doc', place_id: 3 })
|
||||
.success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
fileUpdateRequestSchema.safeParse({ place_id: null, reservation_id: '7' })
|
||||
.success,
|
||||
).toBe(true);
|
||||
expect(fileUpdateRequestSchema.safeParse({ description: 'doc', place_id: 3 }).success).toBe(true);
|
||||
expect(fileUpdateRequestSchema.safeParse({ place_id: null, reservation_id: '7' }).success).toBe(true);
|
||||
expect(fileUpdateRequestSchema.safeParse({}).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fileLinkRequestSchema', () => {
|
||||
it('accepts any subset of reservation/assignment/place ids', () => {
|
||||
expect(fileLinkRequestSchema.safeParse({ reservation_id: 1 }).success).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
fileLinkRequestSchema.safeParse({ assignment_id: '2', place_id: null })
|
||||
.success,
|
||||
).toBe(true);
|
||||
expect(fileLinkRequestSchema.safeParse({ reservation_id: 1 }).success).toBe(true);
|
||||
expect(fileLinkRequestSchema.safeParse({ assignment_id: '2', place_id: null }).success).toBe(true);
|
||||
expect(fileLinkRequestSchema.safeParse({}).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
+37
-68
@@ -2,8 +2,7 @@ import type { TranslationStrings } from '../types';
|
||||
|
||||
const admin: TranslationStrings = {
|
||||
'admin.notifications.title': 'الإشعارات',
|
||||
'admin.notifications.hint':
|
||||
'اختر قناة إشعارات واحدة. يمكن تفعيل واحدة فقط في كل مرة.',
|
||||
'admin.notifications.hint': 'اختر قناة إشعارات واحدة. يمكن تفعيل واحدة فقط في كل مرة.',
|
||||
'admin.notifications.none': 'معطّل',
|
||||
'admin.notifications.email': 'البريد الإلكتروني (SMTP)',
|
||||
'admin.ntfy.hint':
|
||||
@@ -16,18 +15,14 @@ const admin: TranslationStrings = {
|
||||
'admin.notifications.testNtfy': 'إرسال Ntfy تجريبي',
|
||||
'admin.notifications.testNtfySuccess': 'تم إرسال Ntfy التجريبي بنجاح',
|
||||
'admin.notifications.testNtfyFailed': 'فشل إرسال Ntfy التجريبي',
|
||||
'admin.notifications.inappPanel.hint':
|
||||
'الإشعارات داخل التطبيق نشطة دائمًا ولا يمكن تعطيلها بشكل عام.',
|
||||
'admin.notifications.inappPanel.hint': 'الإشعارات داخل التطبيق نشطة دائمًا ولا يمكن تعطيلها بشكل عام.',
|
||||
'admin.notifications.adminWebhookPanel.title': 'Webhook المسؤول',
|
||||
'admin.notifications.adminWebhookPanel.hint':
|
||||
'يُستخدم هذا الـ Webhook حصريًا لإشعارات المسؤول (مثل تنبيهات الإصدارات). وهو مستقل عن Webhooks المستخدمين ويُرسل تلقائيًا عند تعيين رابط URL.',
|
||||
'admin.notifications.adminWebhookPanel.saved': 'تم حفظ رابط Webhook المسؤول',
|
||||
'admin.notifications.adminWebhookPanel.testSuccess':
|
||||
'تم إرسال Webhook الاختباري بنجاح',
|
||||
'admin.notifications.adminWebhookPanel.testFailed':
|
||||
'فشل إرسال Webhook الاختباري',
|
||||
'admin.notifications.adminWebhookPanel.alwaysOnHint':
|
||||
'يُرسل Webhook المسؤول تلقائيًا عند تعيين رابط URL',
|
||||
'admin.notifications.adminWebhookPanel.testSuccess': 'تم إرسال Webhook الاختباري بنجاح',
|
||||
'admin.notifications.adminWebhookPanel.testFailed': 'فشل إرسال Webhook الاختباري',
|
||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'يُرسل Webhook المسؤول تلقائيًا عند تعيين رابط URL',
|
||||
'admin.notifications.adminNtfyPanel.title': 'Ntfy المسؤول',
|
||||
'admin.notifications.adminNtfyPanel.hint':
|
||||
'يُستخدم موضوع Ntfy هذا حصريًا لإشعارات المسؤول (مثل تنبيهات الإصدارات). وهو مستقل عن مواضيع المستخدمين ويُرسل دائمًا عند تهيئته.',
|
||||
@@ -39,23 +34,19 @@ const admin: TranslationStrings = {
|
||||
'admin.notifications.adminNtfyPanel.tokenCleared': 'تم مسح رمز وصول المسؤول',
|
||||
'admin.notifications.adminNtfyPanel.saved': 'تم حفظ إعدادات Ntfy للمسؤول',
|
||||
'admin.notifications.adminNtfyPanel.test': 'إرسال Ntfy تجريبي',
|
||||
'admin.notifications.adminNtfyPanel.testSuccess':
|
||||
'تم إرسال Ntfy التجريبي بنجاح',
|
||||
'admin.notifications.adminNtfyPanel.testSuccess': 'تم إرسال Ntfy التجريبي بنجاح',
|
||||
'admin.notifications.adminNtfyPanel.testFailed': 'فشل إرسال Ntfy التجريبي',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint':
|
||||
'يُرسل Ntfy للمسؤول دائمًا عند تهيئة موضوع',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'يُرسل Ntfy للمسؤول دائمًا عند تهيئة موضوع',
|
||||
'admin.notifications.adminNotificationsHint':
|
||||
'حدد القنوات التي تُسلّم إشعارات المسؤول (مثل تنبيهات الإصدارات). يُرسل الـ Webhook تلقائيًا عند تعيين رابط URL لـ Webhook المسؤول.',
|
||||
'admin.notifications.tripReminders.title': 'تذكيرات الرحلات',
|
||||
'admin.notifications.tripReminders.hint':
|
||||
'إرسال تذكير قبل بدء الرحلة (يتطلب تعيين أيام التذكير على الرحلة).',
|
||||
'admin.notifications.tripReminders.hint': 'إرسال تذكير قبل بدء الرحلة (يتطلب تعيين أيام التذكير على الرحلة).',
|
||||
'admin.notifications.tripReminders.enabled': 'تم تفعيل تذكيرات الرحلات',
|
||||
'admin.notifications.tripReminders.disabled': 'تم تعطيل تذكيرات الرحلات',
|
||||
'admin.smtp.title': 'البريد والإشعارات',
|
||||
'admin.smtp.hint': 'تكوين SMTP لإرسال إشعارات البريد الإلكتروني.',
|
||||
'admin.smtp.testButton': 'إرسال بريد تجريبي',
|
||||
'admin.webhook.hint':
|
||||
'إرسال الإشعارات إلى webhook خارجي (Discord، Slack، إلخ).',
|
||||
'admin.webhook.hint': 'إرسال الإشعارات إلى webhook خارجي (Discord، Slack، إلخ).',
|
||||
'admin.smtp.testSuccess': 'تم إرسال البريد التجريبي بنجاح',
|
||||
'admin.smtp.testFailed': 'فشل إرسال البريد التجريبي',
|
||||
'admin.title': 'الإدارة',
|
||||
@@ -91,8 +82,7 @@ const admin: TranslationStrings = {
|
||||
'admin.toast.cannotDeleteSelf': 'لا يمكنك حذف حسابك الخاص',
|
||||
'admin.toast.userCreated': 'تم إنشاء المستخدم',
|
||||
'admin.toast.createError': 'فشل إنشاء المستخدم',
|
||||
'admin.toast.fieldsRequired':
|
||||
'اسم المستخدم والبريد الإلكتروني وكلمة المرور مطلوبة',
|
||||
'admin.toast.fieldsRequired': 'اسم المستخدم والبريد الإلكتروني وكلمة المرور مطلوبة',
|
||||
'admin.createUser': 'إنشاء مستخدم',
|
||||
'admin.invite.title': 'روابط الدعوة',
|
||||
'admin.invite.subtitle': 'إنشاء روابط تسجيل للاستخدام المحدود',
|
||||
@@ -116,14 +106,11 @@ const admin: TranslationStrings = {
|
||||
'admin.allowRegistration': 'السماح بالتسجيل',
|
||||
'admin.allowRegistrationHint': 'يمكن للمستخدمين الجدد التسجيل بأنفسهم',
|
||||
'admin.requireMfa': 'فرض المصادقة الثنائية (2FA)',
|
||||
'admin.requireMfaHint':
|
||||
'يجب على المستخدمين الذين لا يملكون 2FA إكمال الإعداد في الإعدادات قبل استخدام التطبيق.',
|
||||
'admin.requireMfaHint': 'يجب على المستخدمين الذين لا يملكون 2FA إكمال الإعداد في الإعدادات قبل استخدام التطبيق.',
|
||||
'admin.apiKeys': 'مفاتيح API',
|
||||
'admin.apiKeysHint':
|
||||
'اختياري. يُفعّل بيانات الأماكن الموسعة مثل الصور والطقس.',
|
||||
'admin.apiKeysHint': 'اختياري. يُفعّل بيانات الأماكن الموسعة مثل الصور والطقس.',
|
||||
'admin.mapsKey': 'مفتاح Google Maps API',
|
||||
'admin.mapsKeyHint':
|
||||
'مطلوب للبحث عن الأماكن. احصل عليه من console.cloud.google.com',
|
||||
'admin.mapsKeyHint': 'مطلوب للبحث عن الأماكن. احصل عليه من console.cloud.google.com',
|
||||
'admin.mapsKeyHintLong':
|
||||
'بدون مفتاح API، يُستخدم OpenStreetMap للبحث. مع مفتاح Google يمكن تحميل الصور والتقييمات وساعات العمل أيضًا. احصل عليه من console.cloud.google.com.',
|
||||
'admin.recommended': 'مُوصى به',
|
||||
@@ -134,27 +121,22 @@ const admin: TranslationStrings = {
|
||||
'admin.keyInvalid': 'غير صالح',
|
||||
'admin.keySaved': 'تم حفظ مفاتيح API',
|
||||
'admin.oidcTitle': 'تسجيل الدخول الموحد (OIDC)',
|
||||
'admin.oidcSubtitle':
|
||||
'السماح بتسجيل الدخول عبر مزودين خارجيين مثل Google أو Apple أو Authentik أو Keycloak.',
|
||||
'admin.oidcSubtitle': 'السماح بتسجيل الدخول عبر مزودين خارجيين مثل Google أو Apple أو Authentik أو Keycloak.',
|
||||
'admin.oidcDisplayName': 'الاسم المعروض',
|
||||
'admin.oidcIssuer': 'عنوان URL للمُصدر',
|
||||
'admin.oidcIssuerHint':
|
||||
'عنوان OpenID Connect Issuer URL للمزود. مثال: https://accounts.google.com',
|
||||
'admin.oidcIssuerHint': 'عنوان OpenID Connect Issuer URL للمزود. مثال: https://accounts.google.com',
|
||||
'admin.oidcSaved': 'تم حفظ إعدادات OIDC',
|
||||
'admin.oidcOnlyMode': 'تعطيل المصادقة بكلمة المرور',
|
||||
'admin.oidcOnlyModeHint':
|
||||
'عند التفعيل، يُسمح فقط بتسجيل الدخول عبر SSO. سيتم حظر تسجيل الدخول والتسجيل بكلمة المرور.',
|
||||
'admin.fileTypes': 'أنواع الملفات المسموح بها',
|
||||
'admin.fileTypesHint': 'حدد أنواع الملفات التي يمكن للمستخدمين رفعها.',
|
||||
'admin.fileTypesFormat':
|
||||
'امتدادات مفصولة بفواصل (مثل jpg,png,pdf,doc). استخدم * للسماح بجميع الأنواع.',
|
||||
'admin.fileTypesFormat': 'امتدادات مفصولة بفواصل (مثل jpg,png,pdf,doc). استخدم * للسماح بجميع الأنواع.',
|
||||
'admin.fileTypesSaved': 'تم حفظ إعدادات أنواع الملفات',
|
||||
'admin.placesPhotos.title': 'صور الأماكن',
|
||||
'admin.placesPhotos.subtitle':
|
||||
'جلب الصور من Google Places API. عطّلها للحفاظ على حصة API. صور Wikimedia غير متأثرة.',
|
||||
'admin.placesPhotos.subtitle': 'جلب الصور من Google Places API. عطّلها للحفاظ على حصة API. صور Wikimedia غير متأثرة.',
|
||||
'admin.placesAutocomplete.title': 'الإكمال التلقائي للأماكن',
|
||||
'admin.placesAutocomplete.subtitle':
|
||||
'استخدام Google Places API لاقتراحات البحث. عطّلها للحفاظ على حصة API.',
|
||||
'admin.placesAutocomplete.subtitle': 'استخدام Google Places API لاقتراحات البحث. عطّلها للحفاظ على حصة API.',
|
||||
'admin.placesDetails.title': 'تفاصيل الأماكن',
|
||||
'admin.placesDetails.subtitle':
|
||||
'جلب معلومات تفصيلية عن الأماكن (الساعات، التقييم، الموقع) من Google Places API. عطّلها للحفاظ على حصة API.',
|
||||
@@ -206,15 +188,12 @@ const admin: TranslationStrings = {
|
||||
'admin.addons.catalog.vacay.name': 'الإجازة',
|
||||
'admin.addons.catalog.vacay.description': 'مخطط إجازات شخصي مع عرض تقويم',
|
||||
'admin.addons.catalog.atlas.name': 'الأطلس',
|
||||
'admin.addons.catalog.atlas.description':
|
||||
'خريطة العالم مع الدول التي تمت زيارتها وإحصائيات السفر',
|
||||
'admin.addons.catalog.atlas.description': 'خريطة العالم مع الدول التي تمت زيارتها وإحصائيات السفر',
|
||||
'admin.addons.catalog.collab.name': 'التعاون',
|
||||
'admin.addons.catalog.collab.description':
|
||||
'ملاحظات واستطلاعات ودردشة لحظية لتخطيط الرحلة',
|
||||
'admin.addons.catalog.collab.description': 'ملاحظات واستطلاعات ودردشة لحظية لتخطيط الرحلة',
|
||||
'admin.addons.catalog.memories.name': 'صور (Immich)',
|
||||
'admin.addons.catalog.memories.description': 'شارك صور رحلتك عبر Immich',
|
||||
'admin.addons.catalog.mcp.description':
|
||||
'بروتوكول سياق النموذج لتكامل مساعد الذكاء الاصطناعي',
|
||||
'admin.addons.catalog.mcp.description': 'بروتوكول سياق النموذج لتكامل مساعد الذكاء الاصطناعي',
|
||||
'admin.addons.subtitleBefore': 'فعّل أو عطّل الميزات لتخصيص تجربة ',
|
||||
'admin.addons.subtitleAfter': '.',
|
||||
'admin.addons.enabled': 'مفعّل',
|
||||
@@ -224,8 +203,7 @@ const admin: TranslationStrings = {
|
||||
'admin.addons.type.integration': 'تكامل',
|
||||
'admin.addons.tripHint': 'متاح كعلامة تبويب داخل كل رحلة',
|
||||
'admin.addons.globalHint': 'متاح كقسم مستقل في التنقل الرئيسي',
|
||||
'admin.addons.integrationHint':
|
||||
'خدمات الواجهة الخلفية وتكاملات API بدون صفحة مخصصة',
|
||||
'admin.addons.integrationHint': 'خدمات الواجهة الخلفية وتكاملات API بدون صفحة مخصصة',
|
||||
'admin.addons.toast.updated': 'تم تحديث الإضافة',
|
||||
'admin.addons.toast.error': 'فشل تحديث الإضافة',
|
||||
'admin.addons.noAddons': 'لا توجد إضافات متاحة',
|
||||
@@ -236,8 +214,7 @@ const admin: TranslationStrings = {
|
||||
'admin.weather.forecast': 'توقعات 16 يومًا',
|
||||
'admin.weather.forecastDesc': 'سابقًا 5 أيام (OpenWeatherMap)',
|
||||
'admin.weather.climate': 'بيانات المناخ التاريخية',
|
||||
'admin.weather.climateDesc':
|
||||
'متوسطات آخر 85 سنة للأيام بعد توقعات الـ 16 يومًا',
|
||||
'admin.weather.climateDesc': 'متوسطات آخر 85 سنة للأيام بعد توقعات الـ 16 يومًا',
|
||||
'admin.weather.requests': '10,000 طلب / يوم',
|
||||
'admin.weather.requestsDesc': 'مجاني، بدون مفتاح API',
|
||||
'admin.weather.locationHint':
|
||||
@@ -253,8 +230,7 @@ const admin: TranslationStrings = {
|
||||
'admin.mcpTokens.never': 'أبداً',
|
||||
'admin.mcpTokens.empty': 'لم يتم إنشاء أي رموز MCP بعد',
|
||||
'admin.mcpTokens.deleteTitle': 'حذف الرمز',
|
||||
'admin.mcpTokens.deleteMessage':
|
||||
'سيتم إلغاء هذا الرمز فوراً. سيفقد المستخدم وصوله إلى MCP عبر هذا الرمز.',
|
||||
'admin.mcpTokens.deleteMessage': 'سيتم إلغاء هذا الرمز فوراً. سيفقد المستخدم وصوله إلى MCP عبر هذا الرمز.',
|
||||
'admin.mcpTokens.deleteSuccess': 'تم حذف الرمز',
|
||||
'admin.mcpTokens.deleteError': 'فشل حذف الرمز',
|
||||
'admin.mcpTokens.loadError': 'فشل تحميل الرموز',
|
||||
@@ -265,13 +241,11 @@ const admin: TranslationStrings = {
|
||||
'admin.oauthSessions.created': 'تاريخ الإنشاء',
|
||||
'admin.oauthSessions.empty': 'لا توجد جلسات OAuth نشطة',
|
||||
'admin.oauthSessions.revokeTitle': 'إلغاء الجلسة',
|
||||
'admin.oauthSessions.revokeMessage':
|
||||
'سيتم إلغاء جلسة OAuth هذه فوراً. سيفقد العميل وصوله إلى MCP.',
|
||||
'admin.oauthSessions.revokeMessage': 'سيتم إلغاء جلسة OAuth هذه فوراً. سيفقد العميل وصوله إلى MCP.',
|
||||
'admin.oauthSessions.revokeSuccess': 'تم إلغاء الجلسة',
|
||||
'admin.oauthSessions.revokeError': 'فشل إلغاء الجلسة',
|
||||
'admin.oauthSessions.loadError': 'فشل تحميل جلسات OAuth',
|
||||
'admin.audit.subtitle':
|
||||
'أحداث الأمان والإدارة (النسخ الاحتياطية، المستخدمون، المصادقة الثنائية، الإعدادات).',
|
||||
'admin.audit.subtitle': 'أحداث الأمان والإدارة (النسخ الاحتياطية، المستخدمون، المصادقة الثنائية، الإعدادات).',
|
||||
'admin.audit.empty': 'لا توجد سجلات تدقيق بعد.',
|
||||
'admin.audit.refresh': 'تحديث',
|
||||
'admin.audit.loadMore': 'تحميل المزيد',
|
||||
@@ -298,12 +272,10 @@ const admin: TranslationStrings = {
|
||||
'admin.update.button': 'عرض على GitHub',
|
||||
'admin.update.install': 'تثبيت التحديث',
|
||||
'admin.update.confirmTitle': 'تثبيت التحديث؟',
|
||||
'admin.update.confirmText':
|
||||
'سيتم تحديث TREK من {current} إلى {version}. سيُعاد تشغيل الخادم تلقائيًا بعد ذلك.',
|
||||
'admin.update.confirmText': 'سيتم تحديث TREK من {current} إلى {version}. سيُعاد تشغيل الخادم تلقائيًا بعد ذلك.',
|
||||
'admin.update.dataInfo':
|
||||
'جميع بياناتك (الرحلات، المستخدمون، مفاتيح API، المرفوعات، الإجازة، الأطلس، الميزانيات) ستبقى محفوظة.',
|
||||
'admin.update.warning':
|
||||
'سيكون التطبيق غير متاح لفترة وجيزة أثناء إعادة التشغيل.',
|
||||
'admin.update.warning': 'سيكون التطبيق غير متاح لفترة وجيزة أثناء إعادة التشغيل.',
|
||||
'admin.update.confirm': 'حدّث الآن',
|
||||
'admin.update.installing': 'جارٍ التحديث…',
|
||||
'admin.update.success': 'تم تثبيت التحديث. ستتم إعادة تشغيل الخادم…',
|
||||
@@ -311,8 +283,7 @@ const admin: TranslationStrings = {
|
||||
'admin.update.backupHint': 'نوصي بإنشاء نسخة احتياطية قبل التحديث.',
|
||||
'admin.update.backupLink': 'الذهاب إلى النسخ الاحتياطي',
|
||||
'admin.update.howTo': 'كيفية التحديث',
|
||||
'admin.update.dockerText':
|
||||
'يعمل TREK الخاص بك في Docker. للتحديث إلى {version}، نفّذ الأوامر التالية على الخادم:',
|
||||
'admin.update.dockerText': 'يعمل TREK الخاص بك في Docker. للتحديث إلى {version}، نفّذ الأوامر التالية على الخادم:',
|
||||
'admin.update.reloadHint': 'يرجى إعادة تحميل الصفحة بعد بضع ثوانٍ.',
|
||||
'admin.tabs.permissions': 'الصلاحيات',
|
||||
'admin.notifications.webhook': 'Webhook', // en-fallback
|
||||
@@ -326,13 +297,11 @@ const admin: TranslationStrings = {
|
||||
'admin.passwordLogin': 'Password Login', // en-fallback
|
||||
'admin.passwordLoginHint': 'Allow users to sign in with email and password', // en-fallback
|
||||
'admin.passwordRegistration': 'Password Registration', // en-fallback
|
||||
'admin.passwordRegistrationHint':
|
||||
'Allow new users to register with email and password', // en-fallback
|
||||
'admin.passwordRegistrationHint': 'Allow new users to register with email and password', // en-fallback
|
||||
'admin.oidcLogin': 'SSO Login', // en-fallback
|
||||
'admin.oidcLoginHint': 'Allow users to sign in with SSO', // en-fallback
|
||||
'admin.oidcRegistration': 'SSO Auto-Provisioning', // en-fallback
|
||||
'admin.oidcRegistrationHint':
|
||||
'Automatically create accounts for new SSO users', // en-fallback
|
||||
'admin.oidcRegistrationHint': 'Automatically create accounts for new SSO users', // en-fallback
|
||||
'admin.envOverrideHint':
|
||||
'Password login settings are controlled by the OIDC_ONLY environment variable and cannot be changed here.', // en-fallback
|
||||
'admin.lockoutWarning': 'At least one login method must remain enabled', // en-fallback
|
||||
@@ -342,8 +311,7 @@ const admin: TranslationStrings = {
|
||||
'admin.addons.catalog.journey.description':
|
||||
'Trip tracking & travel journal with check-ins, photos, and daily stories', // en-fallback
|
||||
'admin.passkey.title': 'تسجيل الدخول بمفتاح المرور',
|
||||
'admin.passkey.cardHint':
|
||||
'اسمح للمستخدمين بتسجيل الدخول باستخدام مفاتيح المرور (WebAuthn). معطّل افتراضيًا.',
|
||||
'admin.passkey.cardHint': 'اسمح للمستخدمين بتسجيل الدخول باستخدام مفاتيح المرور (WebAuthn). معطّل افتراضيًا.',
|
||||
'admin.passkey.login': 'تفعيل تسجيل الدخول بمفتاح المرور',
|
||||
'admin.passkey.loginHint':
|
||||
'إظهار خيار "تسجيل الدخول باستخدام مفتاح المرور" والسماح للمستخدمين بتسجيل مفاتيح المرور في إعداداتهم.',
|
||||
@@ -353,19 +321,20 @@ const admin: TranslationStrings = {
|
||||
'admin.passkey.rpIdHint':
|
||||
'النطاق المجرّد الذي تُربط به مفاتيح المرور، مثل trek.example.org. اتركه فارغًا لاشتقاقه من APP_URL. تغييره لاحقًا يُبطل مفاتيح المرور الموجودة.',
|
||||
'admin.passkey.origins': 'الأصول المسموح بها',
|
||||
'admin.passkey.originsHint':
|
||||
'أصول كاملة مفصولة بفواصل، مثل https://trek.example.org. اتركه فارغًا لاستخدام APP_URL.',
|
||||
'admin.passkey.originsHint': 'أصول كاملة مفصولة بفواصل، مثل https://trek.example.org. اتركه فارغًا لاستخدام APP_URL.',
|
||||
'admin.passkey.reset': 'إعادة تعيين مفاتيح المرور',
|
||||
'admin.passkey.resetHint':
|
||||
'إزالة جميع مفاتيح المرور لهذا المستخدم (مثلًا عند فقدان جهاز). سيظل بإمكانه تسجيل الدخول بكلمة المرور.',
|
||||
'admin.passkey.resetConfirm': 'إزالة جميع مفاتيح المرور لـ {name}؟',
|
||||
'admin.passkey.resetDone': 'تمت إزالة {count} من مفاتيح المرور',
|
||||
'admin.defaultSettings.mapProvider': 'محرك الخرائط',
|
||||
'admin.defaultSettings.mapProviderHint': 'الخريطة الافتراضية لجميع المستخدمين على هذا الخادم. لا يزال بإمكان كل مستخدم تجاوزها في إعداداته الخاصة.',
|
||||
'admin.defaultSettings.mapProviderHint':
|
||||
'الخريطة الافتراضية لجميع المستخدمين على هذا الخادم. لا يزال بإمكان كل مستخدم تجاوزها في إعداداته الخاصة.',
|
||||
'admin.defaultSettings.providerLeaflet': 'قياسي (مجاني)',
|
||||
'admin.defaultSettings.providerMapbox': 'Mapbox (ثلاثي الأبعاد)',
|
||||
'admin.defaultSettings.mapboxToken': 'رمز Mapbox المشترك',
|
||||
'admin.defaultSettings.mapboxTokenHint': 'يُستخدم لكل مستخدم لم يُدخل رمزه الخاص — حتى يحصل الخادم بأكمله على Mapbox دون مشاركة المفتاح بشكل فردي. يُخزَّن مشفّرًا.',
|
||||
'admin.defaultSettings.mapboxTokenHint':
|
||||
'يُستخدم لكل مستخدم لم يُدخل رمزه الخاص — حتى يحصل الخادم بأكمله على Mapbox دون مشاركة المفتاح بشكل فردي. يُخزَّن مشفّرًا.',
|
||||
'admin.defaultSettings.mapboxStyle': 'نمط الخريطة',
|
||||
'admin.defaultSettings.mapboxStylePlaceholder': 'اختر نمطًا…',
|
||||
'admin.defaultSettings.mapbox3d': 'المباني والتضاريس ثلاثية الأبعاد',
|
||||
|
||||
@@ -12,10 +12,8 @@ const backup: TranslationStrings = {
|
||||
'backup.createFirst': 'إنشاء أول نسخة',
|
||||
'backup.download': 'تنزيل',
|
||||
'backup.restore': 'استعادة',
|
||||
'backup.confirm.restore':
|
||||
'استعادة النسخة "{name}"؟\n\nسيتم استبدال جميع البيانات الحالية بالنسخة.',
|
||||
'backup.confirm.uploadRestore':
|
||||
'رفع واستعادة النسخة "{name}"؟\n\nسيتم الكتابة فوق جميع البيانات الحالية.',
|
||||
'backup.confirm.restore': 'استعادة النسخة "{name}"؟\n\nسيتم استبدال جميع البيانات الحالية بالنسخة.',
|
||||
'backup.confirm.uploadRestore': 'رفع واستعادة النسخة "{name}"؟\n\nسيتم الكتابة فوق جميع البيانات الحالية.',
|
||||
'backup.confirm.delete': 'حذف النسخة "{name}"؟',
|
||||
'backup.toast.loadError': 'فشل تحميل النسخ الاحتياطية',
|
||||
'backup.toast.created': 'تم إنشاء النسخة الاحتياطية بنجاح',
|
||||
@@ -31,8 +29,7 @@ const backup: TranslationStrings = {
|
||||
'backup.auto.title': 'النسخ الاحتياطي التلقائي',
|
||||
'backup.auto.subtitle': 'نسخ احتياطي تلقائي وفق جدول زمني',
|
||||
'backup.auto.enable': 'تفعيل النسخ التلقائي',
|
||||
'backup.auto.enableHint':
|
||||
'سيتم إنشاء نسخ احتياطية تلقائيًا وفق الجدول المختار',
|
||||
'backup.auto.enableHint': 'سيتم إنشاء نسخ احتياطية تلقائيًا وفق الجدول المختار',
|
||||
'backup.auto.interval': 'الفترة',
|
||||
'backup.auto.hour': 'التنفيذ في الساعة',
|
||||
'backup.auto.hourHint': 'التوقيت المحلي للخادم (تنسيق {format})',
|
||||
@@ -68,8 +65,7 @@ const backup: TranslationStrings = {
|
||||
'backup.restoreConfirmTitle': 'استعادة النسخة الاحتياطية؟',
|
||||
'backup.restoreWarning':
|
||||
'سيتم استبدال جميع البيانات الحالية (الرحلات، الأماكن، المستخدمون، المرفوعات) بالنسخة نهائيًا. لا يمكن التراجع عن ذلك.',
|
||||
'backup.restoreTip':
|
||||
'نصيحة: أنشئ نسخة احتياطية للحالة الحالية قبل الاستعادة.',
|
||||
'backup.restoreTip': 'نصيحة: أنشئ نسخة احتياطية للحالة الحالية قبل الاستعادة.',
|
||||
'backup.restoreConfirm': 'نعم، استعادة',
|
||||
'backup.auto.envLocked': 'Docker', // en-fallback
|
||||
};
|
||||
|
||||
@@ -26,8 +26,7 @@ const budget: TranslationStrings = {
|
||||
'budget.byCategory': 'حسب الفئة',
|
||||
'budget.editTooltip': 'انقر للتعديل',
|
||||
'budget.linkedToReservation': 'مرتبط بحجز — عدّل الاسم هناك',
|
||||
'budget.confirm.deleteCategory':
|
||||
'هل تريد حذف الفئة "{name}" مع {count} إدخالات؟',
|
||||
'budget.confirm.deleteCategory': 'هل تريد حذف الفئة "{name}" مع {count} إدخالات؟',
|
||||
'budget.deleteCategory': 'حذف الفئة',
|
||||
'budget.perPerson': 'لكل شخص',
|
||||
'budget.paid': 'مدفوع',
|
||||
@@ -38,78 +37,85 @@ const budget: TranslationStrings = {
|
||||
'انقر على صورة العضو في بند الميزانية لتحديده باللون الأخضر — وهذا يعني أنه دفع. ثم تُظهر التسوية من يدين لمن وبكم.',
|
||||
'budget.netBalances': 'الأرصدة الصافية',
|
||||
'budget.categoriesLabel': 'فئات',
|
||||
"costs.you": "أنت",
|
||||
"costs.youShort": "أنت",
|
||||
"costs.youLower": "أنت",
|
||||
"costs.youOwe": "عليك",
|
||||
"costs.youOweSub": "عليك أن تدفع للآخرين",
|
||||
"costs.youreOwed": "لك",
|
||||
"costs.youreOwedSub": "على الآخرين أن يدفعوا لك",
|
||||
"costs.totalSpend": "إجمالي إنفاق الرحلة",
|
||||
"costs.totalSpendSub": "عبر جميع المسافرين",
|
||||
"costs.to": "إلى",
|
||||
"costs.from": "من",
|
||||
"costs.allSettled": "لقد سوّيت كل حساباتك",
|
||||
"costs.nothingOwed": "لا شيء مستحق لك",
|
||||
"costs.yourShare": "حصتك",
|
||||
"costs.youPaid": "أنت دفعت",
|
||||
"costs.expenses": "المصروفات",
|
||||
"costs.entries": "{count} إدخالات",
|
||||
"costs.searchPlaceholder": "ابحث في المصروفات…",
|
||||
"costs.filter.all": "الكل",
|
||||
"costs.filter.mine": "دفعتها أنا",
|
||||
"costs.filter.owed": "مستحق لي",
|
||||
"costs.addExpense": "إضافة مصروف",
|
||||
"costs.editExpense": "تعديل المصروف",
|
||||
"costs.noMatch": "لا توجد مصروفات تطابق بحثك.",
|
||||
"costs.emptyText": "لا توجد مصروفات بعد. أضف أول مصروف لك.",
|
||||
"costs.spent": "تم إنفاق {amount}",
|
||||
"costs.noDate": "بدون تاريخ",
|
||||
"costs.noOnePaid": "لم يدفع أحد بعد",
|
||||
"costs.youLent": "أقرضت {amount}",
|
||||
"costs.youBorrowed": "اقترضت {amount}",
|
||||
"costs.settleUp": "تسوية الحساب",
|
||||
"costs.history": "السجل",
|
||||
"costs.everyoneSquare": "الجميع متعادلون",
|
||||
"costs.nothingOutstanding": "لا توجد مدفوعات معلّقة الآن.",
|
||||
"costs.pay": "ادفع",
|
||||
"costs.pays": "يدفع",
|
||||
"costs.settle": "تسوية",
|
||||
"costs.balances": "الأرصدة",
|
||||
"costs.byCategory": "حسب الفئة",
|
||||
"costs.noCategories": "لا توجد مصروفات بعد.",
|
||||
"costs.settleHistory": "سجل التسويات",
|
||||
"costs.noSettlements": "لا توجد مدفوعات مسوّاة بعد.",
|
||||
"costs.paymentsSettled": "تمت تسوية {count} مدفوعات",
|
||||
"costs.paid": "مدفوع",
|
||||
"costs.undo": "تراجع",
|
||||
"costs.whatFor": "لأجل ماذا كان؟",
|
||||
"costs.namePlaceholder": "مثل: عشاء، هدايا تذكارية، وقود…",
|
||||
"costs.totalAmount": "المبلغ الإجمالي",
|
||||
"costs.currency": "العملة",
|
||||
"costs.day": "اليوم",
|
||||
"costs.rateLabel": "1 {from} بـ {to}",
|
||||
"costs.category": "الفئة",
|
||||
"costs.whoPaid": "من دفع؟",
|
||||
"costs.splitBetween": "تقسيم بالتساوي بين",
|
||||
"costs.pickSomeone": "اختر شخصًا واحدًا على الأقل للتقسيم معه.",
|
||||
"costs.splitSummary": "تقسيم على {count} · {amount} لكل واحد",
|
||||
"costs.cat.accommodation": "الإقامة",
|
||||
"costs.cat.food": "الطعام والشراب",
|
||||
"costs.cat.groceries": "البقالة",
|
||||
"costs.cat.transport": "النقل",
|
||||
"costs.cat.flights": "الرحلات الجوية",
|
||||
"costs.cat.activities": "الأنشطة",
|
||||
"costs.cat.sightseeing": "معالم سياحية",
|
||||
"costs.cat.shopping": "التسوق",
|
||||
"costs.cat.fees": "الرسوم والتذاكر",
|
||||
"costs.cat.health": "الصحة",
|
||||
"costs.cat.tips": "البقشيش",
|
||||
"costs.cat.other": "أخرى",
|
||||
"costs.daysCount": "{count} أيام",
|
||||
"costs.travelers": "{count} مسافرين",
|
||||
"costs.liveRate": "سعر مباشر",
|
||||
"costs.settleAll": "تسوية الكل",
|
||||
'costs.you': 'أنت',
|
||||
'costs.youShort': 'أنت',
|
||||
'costs.youLower': 'أنت',
|
||||
'costs.youOwe': 'عليك',
|
||||
'costs.youOweSub': 'عليك أن تدفع للآخرين',
|
||||
'costs.youreOwed': 'لك',
|
||||
'costs.youreOwedSub': 'على الآخرين أن يدفعوا لك',
|
||||
'costs.totalSpend': 'إجمالي إنفاق الرحلة',
|
||||
'costs.totalSpendSub': 'عبر جميع المسافرين',
|
||||
'costs.to': 'إلى',
|
||||
'costs.from': 'من',
|
||||
'costs.allSettled': 'لقد سوّيت كل حساباتك',
|
||||
'costs.nothingOwed': 'لا شيء مستحق لك',
|
||||
'costs.yourShare': 'حصتك',
|
||||
'costs.youPaid': 'أنت دفعت',
|
||||
'costs.expenses': 'المصروفات',
|
||||
'costs.entries': '{count} إدخالات',
|
||||
'costs.searchPlaceholder': 'ابحث في المصروفات…',
|
||||
'costs.filter.all': 'الكل',
|
||||
'costs.filter.mine': 'دفعتها أنا',
|
||||
'costs.filter.owed': 'مستحق لي',
|
||||
'costs.addExpense': 'إضافة مصروف',
|
||||
'costs.editExpense': 'تعديل المصروف',
|
||||
'costs.noMatch': 'لا توجد مصروفات تطابق بحثك.',
|
||||
'costs.emptyText': 'لا توجد مصروفات بعد. أضف أول مصروف لك.',
|
||||
'costs.spent': 'تم إنفاق {amount}',
|
||||
'costs.noDate': 'بدون تاريخ',
|
||||
'costs.noOnePaid': 'لم يدفع أحد بعد',
|
||||
'costs.youLent': 'أقرضت {amount}',
|
||||
'costs.youBorrowed': 'اقترضت {amount}',
|
||||
'costs.settleUp': 'تسوية الحساب',
|
||||
'costs.history': 'السجل',
|
||||
'costs.everyoneSquare': 'الجميع متعادلون',
|
||||
'costs.nothingOutstanding': 'لا توجد مدفوعات معلّقة الآن.',
|
||||
'costs.pay': 'ادفع',
|
||||
'costs.pays': 'يدفع',
|
||||
'costs.settle': 'تسوية',
|
||||
'costs.balances': 'الأرصدة',
|
||||
'costs.byCategory': 'حسب الفئة',
|
||||
'costs.noCategories': 'لا توجد مصروفات بعد.',
|
||||
'costs.settleHistory': 'سجل التسويات',
|
||||
'costs.noSettlements': 'لا توجد مدفوعات مسوّاة بعد.',
|
||||
'costs.paymentsSettled': 'تمت تسوية {count} مدفوعات',
|
||||
'costs.paid': 'مدفوع',
|
||||
'costs.undo': 'تراجع',
|
||||
'costs.whatFor': 'لأجل ماذا كان؟',
|
||||
'costs.namePlaceholder': 'مثل: عشاء، هدايا تذكارية، وقود…',
|
||||
'costs.totalAmount': 'المبلغ الإجمالي',
|
||||
'costs.currency': 'العملة',
|
||||
'costs.day': 'اليوم',
|
||||
'costs.rateLabel': '1 {from} بـ {to}',
|
||||
'costs.category': 'الفئة',
|
||||
'costs.whoPaid': 'من دفع؟',
|
||||
'costs.splitBetween': 'تقسيم بالتساوي بين',
|
||||
'costs.pickSomeone': 'اختر شخصًا واحدًا على الأقل للتقسيم معه.',
|
||||
'costs.splitSummary': 'تقسيم على {count} · {amount} لكل واحد',
|
||||
'costs.cat.accommodation': 'الإقامة',
|
||||
'costs.cat.food': 'الطعام والشراب',
|
||||
'costs.cat.groceries': 'البقالة',
|
||||
'costs.cat.transport': 'النقل',
|
||||
'costs.cat.flights': 'الرحلات الجوية',
|
||||
'costs.cat.activities': 'الأنشطة',
|
||||
'costs.cat.sightseeing': 'معالم سياحية',
|
||||
'costs.cat.shopping': 'التسوق',
|
||||
'costs.cat.fees': 'الرسوم والتذاكر',
|
||||
'costs.cat.health': 'الصحة',
|
||||
'costs.cat.tips': 'البقشيش',
|
||||
'costs.cat.other': 'أخرى',
|
||||
'costs.daysCount': '{count} أيام',
|
||||
'costs.travelers': '{count} مسافرين',
|
||||
'costs.liveRate': 'سعر مباشر',
|
||||
'costs.settleAll': 'تسوية الكل',
|
||||
'costs.payment': 'دفعة',
|
||||
'costs.editPayment': 'تعديل الدفعة',
|
||||
'costs.addPayment': 'إضافة دفعة',
|
||||
'costs.unfinished': 'غير مكتمل',
|
||||
'costs.unfinishedHint': 'في الإجمالي فقط — لم تتم التسوية بعد',
|
||||
'costs.tapToInclude': 'اضغط للتضمين',
|
||||
'costs.amount': 'المبلغ',
|
||||
};
|
||||
|
||||
export default budget;
|
||||
|
||||
@@ -13,8 +13,7 @@ const categories: TranslationStrings = {
|
||||
'categories.defaultName': 'فئة',
|
||||
'categories.update': 'تحديث',
|
||||
'categories.create': 'إنشاء',
|
||||
'categories.confirm.delete':
|
||||
'حذف الفئة؟ لن يتم حذف الأماكن التابعة لهذه الفئة.',
|
||||
'categories.confirm.delete': 'حذف الفئة؟ لن يتم حذف الأماكن التابعة لهذه الفئة.',
|
||||
'categories.toast.loadError': 'فشل تحميل الفئات',
|
||||
'categories.toast.nameRequired': 'يرجى إدخال اسم',
|
||||
'categories.toast.updated': 'تم تحديث الفئة',
|
||||
|
||||
@@ -20,8 +20,7 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.timezoneCustomTzPlaceholder': 'مثال: Asia/Riyadh',
|
||||
'dashboard.timezoneCustomAdd': 'إضافة',
|
||||
'dashboard.timezoneCustomErrorEmpty': 'أدخل معرّف منطقة زمنية',
|
||||
'dashboard.timezoneCustomErrorInvalid':
|
||||
'منطقة زمنية غير صالحة. استخدم صيغة مثل Asia/Riyadh',
|
||||
'dashboard.timezoneCustomErrorInvalid': 'منطقة زمنية غير صالحة. استخدم صيغة مثل Asia/Riyadh',
|
||||
'dashboard.timezoneCustomErrorDuplicate': 'مضافة بالفعل',
|
||||
'dashboard.emptyTitle': 'لا توجد رحلات بعد',
|
||||
'dashboard.emptyText': 'أنشئ رحلتك الأولى وابدأ التخطيط',
|
||||
@@ -55,8 +54,7 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.toast.restoreError': 'فشل الاستعادة',
|
||||
'dashboard.toast.copied': 'تم نسخ الرحلة!',
|
||||
'dashboard.toast.copyError': 'فشل نسخ الرحلة',
|
||||
'dashboard.confirm.delete':
|
||||
'حذف الرحلة "{title}"؟ سيتم حذف جميع الأماكن والخطط نهائيًا.',
|
||||
'dashboard.confirm.delete': 'حذف الرحلة "{title}"؟ سيتم حذف جميع الأماكن والخطط نهائيًا.',
|
||||
'dashboard.editTrip': 'تعديل الرحلة',
|
||||
'dashboard.createTrip': 'إنشاء رحلة جديدة',
|
||||
'dashboard.tripTitle': 'العنوان',
|
||||
@@ -66,10 +64,8 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.startDate': 'تاريخ البداية',
|
||||
'dashboard.endDate': 'تاريخ النهاية',
|
||||
'dashboard.dayCount': 'عدد الأيام',
|
||||
'dashboard.dayCountHint':
|
||||
'عدد الأيام المراد التخطيط لها عندما لا يتم تحديد تواريخ السفر.',
|
||||
'dashboard.noDateHint':
|
||||
'لا يوجد تاريخ محدد. سيتم إنشاء 7 أيام افتراضية ويمكنك تغيير ذلك لاحقًا.',
|
||||
'dashboard.dayCountHint': 'عدد الأيام المراد التخطيط لها عندما لا يتم تحديد تواريخ السفر.',
|
||||
'dashboard.noDateHint': 'لا يوجد تاريخ محدد. سيتم إنشاء 7 أيام افتراضية ويمكنك تغيير ذلك لاحقًا.',
|
||||
'dashboard.coverImage': 'صورة الغلاف',
|
||||
'dashboard.addCoverImage': 'إضافة صورة غلاف',
|
||||
'dashboard.addMembers': 'رفاق السفر',
|
||||
|
||||
@@ -7,8 +7,7 @@ const day: TranslationStrings = {
|
||||
'day.sunrise': 'شروق الشمس',
|
||||
'day.sunset': 'غروب الشمس',
|
||||
'day.hourlyForecast': 'التوقعات بالساعة',
|
||||
'day.climateHint':
|
||||
'متوسطات تاريخية — التوقعات الفعلية متاحة خلال 16 يومًا من هذا التاريخ.',
|
||||
'day.climateHint': 'متوسطات تاريخية — التوقعات الفعلية متاحة خلال 16 يومًا من هذا التاريخ.',
|
||||
'day.noWeather': 'لا تتوفر بيانات طقس. أضف مكانًا بإحداثيات.',
|
||||
'day.overview': 'ملخص اليوم',
|
||||
'day.accommodation': 'الإقامة',
|
||||
|
||||
@@ -3,18 +3,14 @@ import type { TranslationStrings } from '../types';
|
||||
const dayplan: TranslationStrings = {
|
||||
'dayplan.icsTooltip': 'تصدير التقويم (ICS)',
|
||||
'dayplan.emptyDay': 'لا توجد أماكن مخططة لهذا اليوم',
|
||||
'dayplan.cannotReorderTransport':
|
||||
'لا يمكن إعادة ترتيب الحجوزات ذات الوقت الثابت',
|
||||
'dayplan.cannotReorderTransport': 'لا يمكن إعادة ترتيب الحجوزات ذات الوقت الثابت',
|
||||
'dayplan.confirmRemoveTimeTitle': 'إزالة الوقت؟',
|
||||
'dayplan.confirmRemoveTimeBody':
|
||||
'هذا المكان له وقت ثابت ({time}). نقله سيزيل الوقت ويسمح بالترتيب الحر.',
|
||||
'dayplan.confirmRemoveTimeBody': 'هذا المكان له وقت ثابت ({time}). نقله سيزيل الوقت ويسمح بالترتيب الحر.',
|
||||
'dayplan.confirmRemoveTimeAction': 'إزالة الوقت ونقل',
|
||||
'dayplan.confirmDeleteNoteTitle': 'حذف الملاحظة؟',
|
||||
'dayplan.confirmDeleteNoteBody': 'سيتم حذف هذه الملاحظة نهائيًا.',
|
||||
'dayplan.cannotDropOnTimed':
|
||||
'لا يمكن وضع العناصر بين الإدخالات المرتبطة بوقت',
|
||||
'dayplan.cannotBreakChronology':
|
||||
'سيؤدي هذا إلى كسر الترتيب الزمني للعناصر والحجوزات المجدولة',
|
||||
'dayplan.cannotDropOnTimed': 'لا يمكن وضع العناصر بين الإدخالات المرتبطة بوقت',
|
||||
'dayplan.cannotBreakChronology': 'سيؤدي هذا إلى كسر الترتيب الزمني للعناصر والحجوزات المجدولة',
|
||||
'dayplan.addNote': 'إضافة ملاحظة',
|
||||
'dayplan.editNote': 'تعديل الملاحظة',
|
||||
'dayplan.noteAdd': 'إضافة ملاحظة',
|
||||
|
||||
@@ -55,8 +55,7 @@ const ar: NotificationLocale = {
|
||||
body: 'تلقينا طلبًا لإعادة تعيين كلمة المرور لحسابك في TREK. انقر على الزر أدناه لتعيين كلمة مرور جديدة.',
|
||||
ctaIntro: 'إعادة تعيين كلمة المرور',
|
||||
expiry: 'تنتهي صلاحية هذا الرابط خلال 60 دقيقة.',
|
||||
ignore:
|
||||
'إذا لم تطلب هذا، يمكنك تجاهل هذه الرسالة — لن تتغير كلمة المرور الخاصة بك.',
|
||||
ignore: 'إذا لم تطلب هذا، يمكنك تجاهل هذه الرسالة — لن تتغير كلمة المرور الخاصة بك.',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -13,8 +13,7 @@ const files: TranslationStrings = {
|
||||
'files.uploadError': 'فشل الرفع',
|
||||
'files.dropzone': 'أسقط الملفات هنا',
|
||||
'files.dropzoneHint': 'أو انقر للتصفح',
|
||||
'files.allowedTypes':
|
||||
'صور، PDF، DOC، DOCX، XLS، XLSX، TXT، CSV · حد أقصى 50 ميغابايت',
|
||||
'files.allowedTypes': 'صور، PDF، DOC، DOCX، XLS، XLSX، TXT، CSV · حد أقصى 50 ميغابايت',
|
||||
'files.uploading': 'جارٍ الرفع...',
|
||||
'files.filterAll': 'الكل',
|
||||
'files.filterPdf': 'ملفات PDF',
|
||||
@@ -52,10 +51,8 @@ const files: TranslationStrings = {
|
||||
'files.toast.assigned': 'تم إسناد الملف',
|
||||
'files.toast.assignError': 'فشل الإسناد',
|
||||
'files.toast.restoreError': 'فشلت الاستعادة',
|
||||
'files.confirm.permanentDelete':
|
||||
'حذف هذا الملف نهائيًا؟ لا يمكن التراجع عن ذلك.',
|
||||
'files.confirm.emptyTrash':
|
||||
'حذف جميع ملفات سلة المهملات نهائيًا؟ لا يمكن التراجع عن ذلك.',
|
||||
'files.confirm.permanentDelete': 'حذف هذا الملف نهائيًا؟ لا يمكن التراجع عن ذلك.',
|
||||
'files.confirm.emptyTrash': 'حذف جميع ملفات سلة المهملات نهائيًا؟ لا يمكن التراجع عن ذلك.',
|
||||
'files.noteLabel': 'ملاحظة',
|
||||
'files.notePlaceholder': 'أضف ملاحظة...',
|
||||
};
|
||||
|
||||
@@ -10,14 +10,12 @@ const journey: TranslationStrings = {
|
||||
'journey.detail.places': 'أماكن',
|
||||
'journey.skeletons.show': 'إظهار الاقتراحات',
|
||||
'journey.skeletons.hide': 'إخفاء الاقتراحات',
|
||||
'journey.editor.discardChangesConfirm':
|
||||
'لديك تغييرات غير محفوظة. هل تريد تجاهلها؟',
|
||||
'journey.editor.discardChangesConfirm': 'لديك تغييرات غير محفوظة. هل تريد تجاهلها؟',
|
||||
'journey.editor.uploadFailed': 'فشل رفع الصور',
|
||||
'journey.editor.uploadPhotos': 'رفع صور',
|
||||
'journey.editor.uploading': '...جارٍ الرفع',
|
||||
'journey.editor.uploadingProgress': 'جارٍ الرفع {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed':
|
||||
'فشل رفع {failed} من {total} — احفظ مجدداً للمحاولة',
|
||||
'journey.editor.uploadPartialFailed': 'فشل رفع {failed} من {total} — احفظ مجدداً للمحاولة',
|
||||
'journey.editor.fromGallery': 'من المعرض',
|
||||
'journey.editor.addAnother': 'إضافة آخر',
|
||||
'journey.editor.makeFirst': 'جعله الأول',
|
||||
@@ -33,8 +31,7 @@ const journey: TranslationStrings = {
|
||||
'journey.settings.reopenJourney': 'استعادة الرحلة',
|
||||
'journey.settings.archived': 'تم أرشفة الرحلة',
|
||||
'journey.settings.reopened': 'تمت إعادة فتح الرحلة',
|
||||
'journey.settings.endDescription':
|
||||
'يخفي شارة البث المباشر. يمكنك إعادة الفتح في أي وقت.',
|
||||
'journey.settings.endDescription': 'يخفي شارة البث المباشر. يمكنك إعادة الفتح في أي وقت.',
|
||||
'journey.settings.failedToDelete': 'فشل في الحذف',
|
||||
'journey.entries.deleteTitle': 'حذف الإدخال',
|
||||
'journey.photosUploaded': 'تم رفع {count} صورة',
|
||||
@@ -68,8 +65,7 @@ const journey: TranslationStrings = {
|
||||
'journey.notFound': 'Journey not found', // en-fallback
|
||||
'journey.photos': 'Photos', // en-fallback
|
||||
'journey.timelineEmpty': 'No stops yet', // en-fallback
|
||||
'journey.timelineEmptyHint':
|
||||
'Add a check-in or write a journal entry to get started', // en-fallback
|
||||
'journey.timelineEmptyHint': 'Add a check-in or write a journal entry to get started', // en-fallback
|
||||
'journey.status.draft': 'Draft', // en-fallback
|
||||
'journey.status.active': 'Active', // en-fallback
|
||||
'journey.status.completed': 'Completed', // en-fallback
|
||||
@@ -94,30 +90,25 @@ const journey: TranslationStrings = {
|
||||
'journey.editor.titlePlaceholder': 'Give this moment a name...', // en-fallback
|
||||
'journey.editor.bodyPlaceholder': 'Tell the story of this day...', // en-fallback
|
||||
'journey.editor.placePlaceholder': 'Location (optional)', // en-fallback
|
||||
'journey.editor.tagsPlaceholder':
|
||||
'Tags: hidden gem, best meal, must revisit...', // en-fallback
|
||||
'journey.editor.tagsPlaceholder': 'Tags: hidden gem, best meal, must revisit...', // en-fallback
|
||||
'journey.visibility.private': 'Private', // en-fallback
|
||||
'journey.visibility.shared': 'Shared', // en-fallback
|
||||
'journey.visibility.public': 'Public', // en-fallback
|
||||
'journey.emptyState.title': 'Your story starts here', // en-fallback
|
||||
'journey.emptyState.subtitle':
|
||||
'Check in at a place or write your first journal entry', // en-fallback
|
||||
'journey.frontpage.subtitle':
|
||||
"Turn your trips into stories you'll never forget", // en-fallback
|
||||
'journey.emptyState.subtitle': 'Check in at a place or write your first journal entry', // en-fallback
|
||||
'journey.frontpage.subtitle': "Turn your trips into stories you'll never forget", // en-fallback
|
||||
'journey.frontpage.createJourney': 'Create Journey', // en-fallback
|
||||
'journey.frontpage.activeJourney': 'Active Journey', // en-fallback
|
||||
'journey.frontpage.allJourneys': 'All Journeys', // en-fallback
|
||||
'journey.frontpage.journeys': 'journeys', // en-fallback
|
||||
'journey.frontpage.createNew': 'Create a new Journey', // en-fallback
|
||||
'journey.frontpage.createNewSub':
|
||||
'Pick trips, write stories, share your adventures', // en-fallback
|
||||
'journey.frontpage.createNewSub': 'Pick trips, write stories, share your adventures', // en-fallback
|
||||
'journey.frontpage.live': 'Live', // en-fallback
|
||||
'journey.frontpage.synced': 'Synced', // en-fallback
|
||||
'journey.frontpage.continueWriting': 'Continue writing', // en-fallback
|
||||
'journey.frontpage.updated': 'Updated {time}', // en-fallback
|
||||
'journey.frontpage.suggestionLabel': 'Trip just ended', // en-fallback
|
||||
'journey.frontpage.suggestionText':
|
||||
'Turn <strong>{title}</strong> into a Journey', // en-fallback
|
||||
'journey.frontpage.suggestionText': 'Turn <strong>{title}</strong> into a Journey', // en-fallback
|
||||
'journey.frontpage.dismiss': 'Dismiss', // en-fallback
|
||||
'journey.frontpage.journeyName': 'Journey Name', // en-fallback
|
||||
'journey.frontpage.namePlaceholder': 'e.g. Southeast Asia 2026', // en-fallback
|
||||
@@ -131,11 +122,9 @@ const journey: TranslationStrings = {
|
||||
'journey.detail.newEntry': 'New Entry', // en-fallback
|
||||
'journey.detail.editEntry': 'Edit Entry', // en-fallback
|
||||
'journey.detail.noEntries': 'No entries yet', // en-fallback
|
||||
'journey.detail.noEntriesHint':
|
||||
'Add a trip to get started with skeleton entries', // en-fallback
|
||||
'journey.detail.noEntriesHint': 'Add a trip to get started with skeleton entries', // en-fallback
|
||||
'journey.detail.noPhotos': 'No photos yet', // en-fallback
|
||||
'journey.detail.noPhotosHint':
|
||||
'Upload photos to entries or browse your Immich/Synology library', // en-fallback
|
||||
'journey.detail.noPhotosHint': 'Upload photos to entries or browse your Immich/Synology library', // en-fallback
|
||||
'journey.detail.journeyTab': 'Journey', // en-fallback
|
||||
'journey.detail.journeyStats': 'Journey Stats', // en-fallback
|
||||
'journey.detail.syncedTrips': 'Synced Trips', // en-fallback
|
||||
@@ -221,15 +210,13 @@ const journey: TranslationStrings = {
|
||||
'journey.settings.subtitlePlaceholder': 'e.g. Thailand, Vietnam & Cambodia', // en-fallback
|
||||
'journey.settings.delete': 'Delete', // en-fallback
|
||||
'journey.settings.deleteJourney': 'Delete Journey', // en-fallback
|
||||
'journey.settings.deleteMessage':
|
||||
'Delete "{title}"? All entries and photos will be lost.', // en-fallback
|
||||
'journey.settings.deleteMessage': 'Delete "{title}"? All entries and photos will be lost.', // en-fallback
|
||||
'journey.settings.saved': 'Settings saved', // en-fallback
|
||||
'journey.settings.saveFailed': 'Failed to save', // en-fallback
|
||||
'journey.settings.coverUpdated': 'Cover updated', // en-fallback
|
||||
'journey.settings.coverFailed': 'Upload failed', // en-fallback
|
||||
'journey.public.notFound': 'Not Found', // en-fallback
|
||||
'journey.public.notFoundMessage':
|
||||
"This journey doesn't exist or the link has expired.", // en-fallback
|
||||
'journey.public.notFoundMessage': "This journey doesn't exist or the link has expired.", // en-fallback
|
||||
'journey.public.readOnly': 'Read-only · Public Journey', // en-fallback
|
||||
'journey.public.tagline': 'Travel Resource & Exploration Kit', // en-fallback
|
||||
'journey.public.sharedVia': 'Shared via', // en-fallback
|
||||
|
||||
@@ -3,8 +3,7 @@ import type { TranslationStrings } from '../types';
|
||||
const login: TranslationStrings = {
|
||||
'login.error': 'فشل تسجيل الدخول. يرجى التحقق من بياناتك.',
|
||||
'login.tagline': 'رحلاتك.\nخطتك.',
|
||||
'login.description':
|
||||
'خطط لرحلاتك بشكل تعاوني مع خرائط تفاعلية وميزانيات ومزامنة لحظية.',
|
||||
'login.description': 'خطط لرحلاتك بشكل تعاوني مع خرائط تفاعلية وميزانيات ومزامنة لحظية.',
|
||||
'login.features.maps': 'خرائط تفاعلية',
|
||||
'login.features.mapsDesc': 'Google Places ومسارات وتجميع',
|
||||
'login.features.realtime': 'مزامنة فورية',
|
||||
@@ -43,8 +42,7 @@ const login: TranslationStrings = {
|
||||
'login.oidc.invalidState': 'جلسة غير صالحة. حاول مرة أخرى.',
|
||||
'login.demoFailed': 'فشل الدخول إلى العرض التجريبي',
|
||||
'login.oidcSignIn': 'تسجيل الدخول عبر {name}',
|
||||
'login.oidcOnly':
|
||||
'تم تعطيل المصادقة بكلمة المرور. يرجى تسجيل الدخول عبر مزود SSO.',
|
||||
'login.oidcOnly': 'تم تعطيل المصادقة بكلمة المرور. يرجى تسجيل الدخول عبر مزود SSO.',
|
||||
'login.oidcLoggedOut': 'تم تسجيل خروجك. سجّل الدخول مجدداً عبر مزود SSO.',
|
||||
'login.demoHint': 'جرّب العرض التجريبي دون الحاجة للتسجيل',
|
||||
'login.mfaTitle': 'المصادقة الثنائية',
|
||||
@@ -75,18 +73,14 @@ const login: TranslationStrings = {
|
||||
'login.passwordsDontMatch': 'كلمتا المرور غير متطابقتين',
|
||||
'login.mfaCode': 'رمز 2FA',
|
||||
'login.resetPasswordTitle': 'ضبط كلمة مرور جديدة',
|
||||
'login.resetPasswordBody':
|
||||
'اختر كلمة مرور قوية لم تستخدمها هنا من قبل. 8 أحرف على الأقل.',
|
||||
'login.resetPasswordMfaBody':
|
||||
'أدخل رمز 2FA أو رمز النسخ الاحتياطي لإتمام إعادة التعيين.',
|
||||
'login.resetPasswordBody': 'اختر كلمة مرور قوية لم تستخدمها هنا من قبل. 8 أحرف على الأقل.',
|
||||
'login.resetPasswordMfaBody': 'أدخل رمز 2FA أو رمز النسخ الاحتياطي لإتمام إعادة التعيين.',
|
||||
'login.resetPasswordSubmit': 'إعادة تعيين كلمة المرور',
|
||||
'login.resetPasswordVerify': 'تحقق وأعد التعيين',
|
||||
'login.resetPasswordSuccessTitle': 'تم تحديث كلمة المرور',
|
||||
'login.resetPasswordSuccessBody':
|
||||
'يمكنك الآن تسجيل الدخول بكلمة المرور الجديدة.',
|
||||
'login.resetPasswordSuccessBody': 'يمكنك الآن تسجيل الدخول بكلمة المرور الجديدة.',
|
||||
'login.resetPasswordInvalidLink': 'رابط إعادة تعيين غير صالح',
|
||||
'login.resetPasswordInvalidLinkBody':
|
||||
'هذا الرابط مفقود أو تالف. اطلب رابطًا جديدًا للمتابعة.',
|
||||
'login.resetPasswordInvalidLinkBody': 'هذا الرابط مفقود أو تالف. اطلب رابطًا جديدًا للمتابعة.',
|
||||
'login.resetPasswordFailed': 'فشلت إعادة التعيين. ربما انتهت صلاحية الرابط.',
|
||||
'login.emailPlaceholder': 'your@email.com', // en-fallback
|
||||
'login.passkey.signIn': 'تسجيل الدخول باستخدام مفتاح المرور',
|
||||
|
||||
@@ -3,8 +3,7 @@ import type { TranslationStrings } from '../types';
|
||||
const memories: TranslationStrings = {
|
||||
'memories.title': 'صور',
|
||||
'memories.notConnected': 'Immich غير متصل',
|
||||
'memories.notConnectedHint':
|
||||
'قم بتوصيل Immich في الإعدادات لعرض صور رحلتك هنا.',
|
||||
'memories.notConnectedHint': 'قم بتوصيل Immich في الإعدادات لعرض صور رحلتك هنا.',
|
||||
'memories.notConnectedMultipleHint':
|
||||
'قم بتوصيل أحد موفري الصور هؤلاء: {provider_names} في الإعدادات لتتمكن من إضافة صور إلى هذه الرحلة.',
|
||||
'memories.noDates': 'أضف تواريخ لرحلتك لتحميل الصور.',
|
||||
@@ -24,8 +23,7 @@ const memories: TranslationStrings = {
|
||||
'memories.providerOTP': 'رمز MFA (إذا كان مفعلاً)',
|
||||
'memories.skipSSLVerification': 'تخطي التحقق من شهادة SSL',
|
||||
'memories.immichAutoUpload': 'نسخ صور الرحلة إلى Immich عند الرفع',
|
||||
'memories.providerUrlHintSynology':
|
||||
'أدرج مسار تطبيق Photos في URL، مثل https://nas:5001/photo',
|
||||
'memories.providerUrlHintSynology': 'أدرج مسار تطبيق Photos في URL، مثل https://nas:5001/photo',
|
||||
'memories.testConnection': 'اختبار الاتصال',
|
||||
'memories.testShort': 'اختبار',
|
||||
'memories.testFirst': 'اختبر الاتصال أولاً',
|
||||
@@ -34,8 +32,7 @@ const memories: TranslationStrings = {
|
||||
'memories.connectionSuccess': 'تم الاتصال بـ Immich',
|
||||
'memories.connectionError': 'تعذر الاتصال بـ Immich',
|
||||
'memories.saved': 'تم حفظ إعدادات {provider_name}',
|
||||
'memories.providerDisconnectedBanner':
|
||||
'اتصالك بـ {provider_name} مفقود. أعد الاتصال في الإعدادات لعرض الصور.',
|
||||
'memories.providerDisconnectedBanner': 'اتصالك بـ {provider_name} مفقود. أعد الاتصال في الإعدادات لعرض الصور.',
|
||||
'memories.saveError': 'تعذّر حفظ إعدادات {provider_name}',
|
||||
'memories.addPhotos': 'إضافة صور',
|
||||
'memories.linkAlbum': 'ربط ألبوم',
|
||||
@@ -59,8 +56,7 @@ const memories: TranslationStrings = {
|
||||
'memories.tripDates': 'تواريخ الرحلة',
|
||||
'memories.allPhotos': 'جميع الصور',
|
||||
'memories.confirmShareTitle': 'مشاركة مع أعضاء الرحلة؟',
|
||||
'memories.confirmShareHint':
|
||||
'{count} صور ستكون مرئية لجميع أعضاء هذه الرحلة. يمكنك جعل الصور الفردية خاصة لاحقًا.',
|
||||
'memories.confirmShareHint': '{count} صور ستكون مرئية لجميع أعضاء هذه الرحلة. يمكنك جعل الصور الفردية خاصة لاحقًا.',
|
||||
'memories.confirmShareButton': 'مشاركة الصور',
|
||||
'memories.error.loadAlbums': 'فشل تحميل الألبومات',
|
||||
'memories.error.linkAlbum': 'فشل ربط الألبوم',
|
||||
|
||||
@@ -35,7 +35,6 @@ const notif: TranslationStrings = {
|
||||
'notif.generic.title': 'إشعار',
|
||||
'notif.generic.text': 'لديك إشعار جديد',
|
||||
'notif.dev.unknown_event.title': '[DEV] حدث غير معروف',
|
||||
'notif.dev.unknown_event.text':
|
||||
'نوع الحدث "{event}" غير مسجل في EVENT_NOTIFICATION_CONFIG',
|
||||
'notif.dev.unknown_event.text': 'نوع الحدث "{event}" غير مسجل في EVENT_NOTIFICATION_CONFIG',
|
||||
};
|
||||
export default notif;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user