mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
1178 lines
81 KiB
TypeScript
1178 lines
81 KiB
TypeScript
import axios, { AxiosInstance } from 'axios'
|
|
import type { z } from 'zod'
|
|
import type { Place } from '../types'
|
|
import {
|
|
weatherResultSchema, type WeatherResult,
|
|
inAppListResultSchema, type InAppListResult,
|
|
unreadCountResultSchema, type UnreadCountResult,
|
|
channelTestResultSchema,
|
|
mapsSearchResultSchema, mapsAutocompleteResultSchema, mapsPlaceDetailsResultSchema,
|
|
mapsPlacePhotoResultSchema, mapsReverseResultSchema, mapsResolveUrlResultSchema,
|
|
mapsPlaceEnrichmentResultSchema,
|
|
type NotificationRespondRequest,
|
|
type SettingUpsertRequest, type SettingsBulkRequest,
|
|
type JourneyCreateRequest, type JourneyAddTripRequest,
|
|
type JourneyReorderEntriesRequest, type JourneyProviderPhotosRequest,
|
|
type JourneyShareLinkRequest,
|
|
type RegisterRequest, type LoginRequest, type ForgotPasswordRequest,
|
|
type ResetPasswordRequest, type ChangePasswordRequest,
|
|
type MfaVerifyLoginRequest, type MfaEnableRequest, type McpTokenCreateRequest,
|
|
type TripAddMemberRequest, type TripTransferOwnershipRequest,
|
|
type TripCreateGuestRequest, type TripRenameGuestRequest, type AssignmentReorderRequest,
|
|
type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest,
|
|
type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest, type ActiveTripResponse,
|
|
type DayCreateRequest, type DayUpdateRequest, type DayReorderRequest,
|
|
type PlaceCreateRequest, type PlaceUpdateRequest,
|
|
type ReservationCreateRequest, type ReservationUpdateRequest,
|
|
type AccommodationCreateRequest, type AccommodationUpdateRequest,
|
|
type BudgetCreateItemRequest, type BudgetUpdateItemRequest,
|
|
type PackingCreateItemRequest, type PackingUpdateItemRequest, type PackingSetSharingRequest,
|
|
type TodoCreateItemRequest, type TodoUpdateItemRequest,
|
|
type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest, type AssignmentTransportRequest,
|
|
type PlaceBulkDeleteRequest,
|
|
type PlaceBulkUpdateRequest,
|
|
type DayNoteCreateRequest, type DayNoteUpdateRequest,
|
|
type PackingImportRequest, type PackingBagMembersRequest, type PackingUpdateBagRequest,
|
|
type PackingCategoryAssigneesRequest, type PackingApplyTemplateRequest,
|
|
type BudgetUpdateMembersRequest, type BudgetToggleMemberPaidRequest, type BudgetReorderCategoriesRequest,
|
|
type TodoCategoryAssigneesRequest,
|
|
type CollabNoteCreateRequest, type CollabNoteUpdateRequest, type CollabPollCreateRequest,
|
|
type CollabPollVoteRequest, type CollabMessageCreateRequest, type CollabReactionRequest,
|
|
type FileUpdateRequest, type FileLinkRequest,
|
|
type CreateTagRequest, type UpdateTagRequest,
|
|
type CreateCategoryRequest, type UpdateCategoryRequest,
|
|
type PlaceImportListRequest,
|
|
type BookingImportPreviewItem,
|
|
type BookingImportPreviewResponse,
|
|
type BookingImportConfirmResponse,
|
|
type BookingImportMode,
|
|
} from '@trek/shared'
|
|
import { getSocketId } from './websocket'
|
|
import { probeNow } from '../sync/connectivity'
|
|
|
|
/**
|
|
* Validate a response payload against its @trek/shared Zod schema — but only in
|
|
* dev, and never throwing. A drift between the server contract and the client's
|
|
* expected shape is surfaced as a console warning during development; in
|
|
* production (and on any mismatch) the data passes through untouched, so adding
|
|
* validation can never break a working call. This is the typed-request helper
|
|
* the FE adopts per domain as each backend module lands on @trek/shared.
|
|
*/
|
|
const API_DEV = Boolean((import.meta as { env?: { DEV?: boolean } }).env?.DEV)
|
|
export function parseInDev<S extends z.ZodTypeAny>(schema: S, data: unknown, label: string): z.infer<S> {
|
|
if (API_DEV) {
|
|
const result = schema.safeParse(data)
|
|
if (!result.success) {
|
|
console.warn(`[api] ${label}: response did not match the @trek/shared schema`, result.error.issues)
|
|
}
|
|
}
|
|
return data as z.infer<S>
|
|
}
|
|
|
|
/**
|
|
* Same dev-only drift check as parseInDev, but passes the payload straight
|
|
* through with its original inferred type instead of the schema type. Use this
|
|
* for endpoints whose existing consumers rely on the loose `r.data` type — it
|
|
* adds the development contract-drift warning without retyping the public
|
|
* surface (so it can never break a consumer that worked before).
|
|
*/
|
|
function checkInDev<T>(schema: z.ZodTypeAny, data: T, label: string): T {
|
|
if (API_DEV) {
|
|
const result = schema.safeParse(data)
|
|
if (!result.success) {
|
|
console.warn(`[api] ${label}: response did not match the @trek/shared schema`, result.error.issues)
|
|
}
|
|
}
|
|
return data
|
|
}
|
|
const RATE_LIMIT_MESSAGES: Record<string, string> = {
|
|
en: 'Too many attempts. Please try again later.',
|
|
de: 'Zu viele Versuche. Bitte versuchen Sie es später erneut.',
|
|
es: 'Demasiados intentos. Inténtelo de nuevo más tarde.',
|
|
fr: 'Trop de tentatives. Veuillez réessayer plus tard.',
|
|
hu: 'Túl sok próbálkozás. Kérjük, próbálja újra később.',
|
|
nl: 'Te veel pogingen. Probeer het later opnieuw.',
|
|
br: 'Muitas tentativas. Tente novamente mais tarde.',
|
|
cs: 'Příliš mnoho pokusů. Zkuste to prosím znovu.',
|
|
pl: 'Zbyt wiele prób. Spróbuj ponownie później.',
|
|
ru: 'Слишком много попыток. Попробуйте позже.',
|
|
zh: '尝试次数过多,请稍后再试。',
|
|
'zh-TW': '嘗試次數過多,請稍後再試。',
|
|
it: 'Troppi tentativi. Riprova più tardi.',
|
|
tr: 'Çok fazla deneme. Lütfen daha sonra tekrar deneyin.',
|
|
ar: 'محاولات كثيرة جدًا. يرجى المحاولة لاحقًا.',
|
|
id: 'Terlalu banyak percobaan. Coba lagi nanti.',
|
|
ja: '試行回数が多すぎます。時間をおいて再度お試しください。',
|
|
ko: '시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요.',
|
|
uk: 'Занадто багато спроб. Спробуйте пізніше.',
|
|
sv: 'För många försök. Prova igen senare.',
|
|
ca: 'Massa intents. Torneu-ho a provar més tard.',
|
|
gr: 'Πάρα πολλές προσπάθειες. Δοκιμάστε ξανά αργότερα.',
|
|
vi: 'Quá nhiều lần thử. Vui lòng thử lại sau.',
|
|
}
|
|
|
|
function translateRateLimit(): string {
|
|
const fallback = RATE_LIMIT_MESSAGES['en']!
|
|
try {
|
|
const lang = localStorage.getItem('app_language') || 'en'
|
|
return RATE_LIMIT_MESSAGES[lang] ?? fallback
|
|
} catch {
|
|
return fallback
|
|
}
|
|
}
|
|
|
|
export const apiClient: AxiosInstance = axios.create({
|
|
baseURL: '/api',
|
|
withCredentials: true,
|
|
timeout: 8000,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
})
|
|
|
|
const MUTATING_METHODS = new Set(['post', 'put', 'patch', 'delete'])
|
|
|
|
// Request interceptor - add socket ID + idempotency key for mutating requests
|
|
apiClient.interceptors.request.use(
|
|
(config) => {
|
|
const sid = getSocketId()
|
|
if (sid) {
|
|
config.headers['X-Socket-Id'] = sid
|
|
}
|
|
// Attach a per-request idempotency key to all write operations so the
|
|
// server can deduplicate retried requests (e.g. network blips).
|
|
// The mutation queue sets its own pre-generated key; skip if already set.
|
|
const method = (config.method ?? '').toLowerCase()
|
|
if (MUTATING_METHODS.has(method) && !config.headers['X-Idempotency-Key']) {
|
|
const key = typeof crypto !== 'undefined' && crypto.randomUUID
|
|
? crypto.randomUUID()
|
|
: Math.random().toString(36).slice(2)
|
|
config.headers['X-Idempotency-Key'] = key
|
|
}
|
|
return config
|
|
},
|
|
(error) => Promise.reject(error)
|
|
)
|
|
|
|
export function isAuthPublicPath(pathname: string): boolean {
|
|
const publicPaths = ['/login', '/register', '/forgot-password', '/reset-password']
|
|
const publicPrefixes = ['/shared/', '/public/']
|
|
return publicPaths.includes(pathname) || publicPrefixes.some((p) => pathname.startsWith(p))
|
|
}
|
|
|
|
// Unregisters the SW before reloading so the navigation reaches the network.
|
|
// Without this, WorkBox's NavigationRoute serves the cached SPA shell and the
|
|
// upstream proxy (CF Access / Pangolin) never gets to challenge the user.
|
|
async function unregisterSWAndReload(): Promise<void> {
|
|
try {
|
|
const reg = await navigator.serviceWorker?.getRegistration()
|
|
if (reg) await reg.unregister()
|
|
} catch { /* ignore */ }
|
|
window.location.reload()
|
|
}
|
|
|
|
// Response interceptor - handle 401, 403 MFA, 429 rate limit, proxy auth challenges
|
|
apiClient.interceptors.response.use(
|
|
(response) => {
|
|
sessionStorage.removeItem('proxy_reauth_attempted')
|
|
return response
|
|
},
|
|
async (error) => {
|
|
// CF Access / Pangolin / similar: cross-origin redirect from /api/* surfaces
|
|
// as a CORS error with no response object. Probe the health endpoint to
|
|
// distinguish a proxy auth challenge from a genuine outage. If the server
|
|
// is reachable, a top-level reload lets the edge proxy run its auth flow.
|
|
if (!error.response && navigator.onLine) {
|
|
// Only an actual edge-proxy auth wall warrants tearing down the SW to
|
|
// reauth: a reachable proxy (CF Access / Pangolin) that intercepts /api
|
|
// with a cross-origin redirect or an HTML login page. A genuine offline
|
|
// boot ALSO lands here — navigator.onLine reflects a network interface,
|
|
// not reachability, and is routinely true on mobile while offline. So
|
|
// gate strictly on a positive proxy signal; on plain offline do nothing
|
|
// and let the request reject so the cached shell + IndexedDB serve the
|
|
// app. Unregistering the SW here reloaded into a dead network and broke
|
|
// PWA offline mode (#1346).
|
|
const state = await probeNow()
|
|
if (state === 'proxy-wall') {
|
|
const { pathname } = window.location
|
|
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
|
|
sessionStorage.setItem('proxy_reauth_attempted', '1')
|
|
await unregisterSWAndReload()
|
|
return Promise.reject(error)
|
|
}
|
|
}
|
|
}
|
|
// Pangolin header-auth extended compatibility mode: returns 401 with an
|
|
// HTML body (a JS redirect page) instead of a 302. TREK's own 401s are
|
|
// always application/json, so checking for text/html is unambiguous.
|
|
if (error.response?.status === 401) {
|
|
const ct = (error.response.headers?.['content-type'] as string | undefined) ?? ''
|
|
if (ct.includes('text/html')) {
|
|
const { pathname } = window.location
|
|
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
|
|
sessionStorage.setItem('proxy_reauth_attempted', '1')
|
|
await unregisterSWAndReload()
|
|
return Promise.reject(error)
|
|
}
|
|
}
|
|
}
|
|
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
|
|
const { pathname } = window.location
|
|
if (!isAuthPublicPath(pathname)) {
|
|
const currentPath = pathname + window.location.search + window.location.hash
|
|
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
|
|
}
|
|
}
|
|
if (
|
|
error.response?.status === 403 &&
|
|
(error.response?.data as { code?: string } | undefined)?.code === 'MFA_REQUIRED' &&
|
|
!window.location.pathname.startsWith('/settings')
|
|
) {
|
|
window.location.href = '/settings?mfa=required'
|
|
}
|
|
if (error.response?.status === 429) {
|
|
const translated = translateRateLimit()
|
|
const data = error.response.data
|
|
// Only a plain object body carries an `error` field worth overwriting;
|
|
// an array (a validation-error list) or a string is replaced outright.
|
|
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
|
(data as { error?: string }).error = translated
|
|
} else {
|
|
error.response.data = { error: translated }
|
|
}
|
|
error.message = translated
|
|
}
|
|
return Promise.reject(error)
|
|
}
|
|
)
|
|
|
|
/**
|
|
* POST a FormData body — the ONLY way this client should upload a file.
|
|
*
|
|
* The shared axios instance carries `timeout: 8000`, and axios' timeout is a whole-
|
|
* request deadline rather than an idle one. A file upload that takes longer than 8s to
|
|
* push its body — a phone photo on a slow uplink, a 500 MB document — is aborted
|
|
* mid-stream, which the server reports as a multer "Request aborted" (#1495).
|
|
*
|
|
* Every upload therefore has to opt out with `timeout: 0`. That opt-out used to be
|
|
* hand-written per call site, so it was forgotten on 7 of 15 — including the two 500 MB
|
|
* endpoints (documents, backup restore). Centralizing makes the correct behavior the
|
|
* default instead of something you have to remember.
|
|
*
|
|
* The Content-Type is set for clarity only: axios unsets it for FormData in the browser
|
|
* so the platform can generate the multipart boundary.
|
|
*/
|
|
export interface UploadOptions {
|
|
onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void
|
|
idempotencyKey?: string
|
|
signal?: AbortSignal
|
|
}
|
|
|
|
export function postMultipart<T = any>(url: string, formData: FormData, opts?: UploadOptions): Promise<T> {
|
|
return apiClient.post(url, formData, {
|
|
headers: {
|
|
'Content-Type': 'multipart/form-data',
|
|
...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}),
|
|
},
|
|
timeout: 0,
|
|
onUploadProgress: opts?.onUploadProgress,
|
|
signal: opts?.signal,
|
|
}).then(r => r.data as T)
|
|
}
|
|
|
|
export const authApi = {
|
|
register: (data: RegisterRequest) => apiClient.post('/auth/register', data).then(r => r.data),
|
|
validateInvite: (token: string) => apiClient.get(`/auth/invite/${token}`).then(r => r.data),
|
|
login: (data: LoginRequest) => apiClient.post('/auth/login', data).then(r => r.data),
|
|
verifyMfaLogin: (data: MfaVerifyLoginRequest) => apiClient.post('/auth/mfa/verify-login', data).then(r => r.data),
|
|
mfaSetup: () => apiClient.post('/auth/mfa/setup', {}).then(r => r.data),
|
|
mfaEnable: (data: MfaEnableRequest) => apiClient.post('/auth/mfa/enable', data).then(r => r.data as { success: boolean; mfa_enabled: boolean; backup_codes?: string[] }),
|
|
mfaDisable: (data: { password: string; code: string }) => apiClient.post('/auth/mfa/disable', data).then(r => r.data),
|
|
me: () => apiClient.get('/auth/me').then(r => r.data),
|
|
updateMapsKey: (key: string | null) => apiClient.put('/auth/me/maps-key', { maps_api_key: key }).then(r => r.data),
|
|
updateApiKeys: (data: Record<string, string | null>) => apiClient.put('/auth/me/api-keys', data).then(r => r.data),
|
|
updateSettings: (data: Record<string, unknown>) => apiClient.put('/auth/me/settings', data).then(r => r.data),
|
|
getSettings: () => apiClient.get('/auth/me/settings').then(r => r.data),
|
|
listUsers: () => apiClient.get('/auth/users').then(r => r.data),
|
|
uploadAvatar: (formData: FormData) => postMultipart('/auth/avatar', formData),
|
|
deleteAvatar: () => apiClient.delete('/auth/avatar').then(r => r.data),
|
|
getAppConfig: () => apiClient.get('/auth/app-config').then(r => r.data),
|
|
updateAppSettings: (data: Record<string, unknown>) => apiClient.put('/auth/app-settings', data).then(r => r.data),
|
|
validateKeys: () => apiClient.get('/auth/validate-keys').then(r => r.data),
|
|
travelStats: () => apiClient.get('/auth/travel-stats').then(r => r.data),
|
|
changePassword: (data: ChangePasswordRequest) => apiClient.put('/auth/me/password', data).then(r => r.data),
|
|
forgotPassword: (data: ForgotPasswordRequest) => apiClient.post('/auth/forgot-password', data).then(r => r.data as { ok: true }),
|
|
resetPassword: (data: ResetPasswordRequest) => apiClient.post('/auth/reset-password', data).then(r => r.data as { success?: true; mfa_required?: true }),
|
|
deleteOwnAccount: () => apiClient.delete('/auth/me').then(r => r.data),
|
|
demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data),
|
|
mcpTokens: {
|
|
list: () => apiClient.get('/auth/mcp-tokens').then(r => r.data),
|
|
create: (name: string) => apiClient.post('/auth/mcp-tokens', { name } satisfies McpTokenCreateRequest).then(r => r.data),
|
|
delete: (id: number) => apiClient.delete(`/auth/mcp-tokens/${id}`).then(r => r.data),
|
|
},
|
|
passkey: {
|
|
registerOptions: (password: string) => apiClient.post('/auth/passkey/register/options', { password }).then(r => r.data),
|
|
registerVerify: (attestationResponse: unknown, name?: string) => apiClient.post('/auth/passkey/register/verify', { attestationResponse, name }).then(r => r.data),
|
|
loginOptions: () => apiClient.post('/auth/passkey/login/options', {}).then(r => r.data),
|
|
loginVerify: (assertionResponse: unknown) => apiClient.post('/auth/passkey/login/verify', { assertionResponse }).then(r => r.data as { token: string; user: Record<string, unknown> }),
|
|
list: () => apiClient.get('/auth/passkey/credentials').then(r => r.data as { credentials: PasskeyCredential[] }),
|
|
rename: (id: number, name: string) => apiClient.patch(`/auth/passkey/credentials/${id}`, { name }).then(r => r.data),
|
|
delete: (id: number, password: string) => apiClient.delete(`/auth/passkey/credentials/${id}`, { data: { password } }).then(r => r.data),
|
|
},
|
|
}
|
|
|
|
export interface PasskeyCredential {
|
|
id: number
|
|
name: string | null
|
|
device_type: string | null
|
|
backed_up: boolean
|
|
created_at: string
|
|
last_used_at: string | null
|
|
}
|
|
|
|
export const oauthApi = {
|
|
/** Validate OAuth authorize params — called by consent page on load */
|
|
validate: (params: {
|
|
response_type: string
|
|
client_id: string
|
|
redirect_uri: string
|
|
scope: string
|
|
state?: string
|
|
code_challenge: string
|
|
code_challenge_method: string
|
|
resource?: string
|
|
}) => apiClient.get('/oauth/authorize/validate', { params }).then(r => r.data),
|
|
|
|
/** Submit user consent (approve or deny) */
|
|
authorize: (body: {
|
|
client_id: string
|
|
redirect_uri: string
|
|
scope: string
|
|
state?: string
|
|
code_challenge: string
|
|
code_challenge_method: string
|
|
approved: boolean
|
|
resource?: string
|
|
}) => apiClient.post('/oauth/authorize', body).then(r => r.data),
|
|
|
|
clients: {
|
|
list: () => apiClient.get('/oauth/clients').then(r => r.data),
|
|
create: (data: { name: string; redirect_uris?: string[]; allowed_scopes: string[]; allows_client_credentials?: boolean }) =>
|
|
apiClient.post('/oauth/clients', data).then(r => r.data),
|
|
rotate: (id: string) => apiClient.post(`/oauth/clients/${id}/rotate`).then(r => r.data),
|
|
delete: (id: string) => apiClient.delete(`/oauth/clients/${id}`).then(r => r.data),
|
|
},
|
|
|
|
sessions: {
|
|
list: () => apiClient.get('/oauth/sessions').then(r => r.data),
|
|
revoke: (id: number) => apiClient.delete(`/oauth/sessions/${id}`).then(r => r.data),
|
|
},
|
|
}
|
|
|
|
export const tripsApi = {
|
|
list: (params?: Record<string, unknown>) => apiClient.get('/trips', { params }).then(r => r.data),
|
|
create: (data: TripCreateRequest) => apiClient.post('/trips', data).then(r => r.data),
|
|
get: (id: number | string) => apiClient.get(`/trips/${id}`).then(r => r.data),
|
|
// The startup redirect's one lookup — deliberately not list(), which would pull
|
|
// every trip with its counts just to read one id.
|
|
active: (): Promise<ActiveTripResponse> => apiClient.get('/trips/active').then(r => r.data),
|
|
update: (id: number | string, data: TripUpdateRequest) => apiClient.put(`/trips/${id}`, data).then(r => r.data),
|
|
delete: (id: number | string) => apiClient.delete(`/trips/${id}`).then(r => r.data),
|
|
uploadCover: (id: number | string, formData: FormData) => postMultipart(`/trips/${id}/cover`, formData),
|
|
searchCoverImages: (query: string) => apiClient.get('/trips/cover-images/search', { params: { query } }).then(r => r.data),
|
|
archive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: true }).then(r => r.data),
|
|
unarchive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: false }).then(r => r.data),
|
|
getMembers: (id: number | string) => apiClient.get(`/trips/${id}/members`).then(r => r.data),
|
|
addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier } satisfies TripAddMemberRequest).then(r => r.data),
|
|
removeMember: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data),
|
|
transferOwnership: (id: number | string, newOwnerId: number) => apiClient.post(`/trips/${id}/transfer`, { newOwnerId } satisfies TripTransferOwnershipRequest).then(r => r.data),
|
|
createGuest: (id: number | string, name: string) => apiClient.post(`/trips/${id}/guests`, { name } satisfies TripCreateGuestRequest).then(r => r.data),
|
|
renameGuest: (id: number | string, userId: number, name: string) => apiClient.put(`/trips/${id}/guests/${userId}`, { name } satisfies TripRenameGuestRequest).then(r => r.data),
|
|
deleteGuest: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/guests/${userId}`).then(r => r.data),
|
|
copy: (id: number | string, data?: TripCopyRequest) => apiClient.post(`/trips/${id}/copy`, data || {}).then(r => r.data),
|
|
bundle: (id: number | string) => apiClient.get(`/trips/${id}/bundle`).then(r => r.data),
|
|
}
|
|
|
|
export const daysApi = {
|
|
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/days`).then(r => r.data),
|
|
create: (tripId: number | string, data: DayCreateRequest) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
|
|
update: (tripId: number | string, dayId: number | string, data: DayUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data),
|
|
// Whole-day default route mode (#1281); per-segment leg modes override it.
|
|
updateTransport: (tripId: number | string, dayId: number | string, mode: string | null) => apiClient.put(`/trips/${tripId}/days/${dayId}/transport`, { transport_mode: mode }).then(r => r.data),
|
|
delete: (tripId: number | string, dayId: number | string) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data),
|
|
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/reorder`, { orderedIds } satisfies DayReorderRequest).then(r => r.data),
|
|
}
|
|
|
|
export const placesApi = {
|
|
list: (tripId: number | string, params?: Record<string, unknown>) => apiClient.get(`/trips/${tripId}/places`, { params }).then(r => r.data),
|
|
create: (tripId: number | string, data: PlaceCreateRequest) => apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data),
|
|
get: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}`).then(r => r.data),
|
|
update: (tripId: number | string, id: number | string, data: PlaceUpdateRequest) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
|
|
delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
|
|
searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data),
|
|
uploadImage: (tripId: number | string, id: number | string, file: File) => {
|
|
const fd = new FormData()
|
|
fd.append('image', file)
|
|
return postMultipart<{ place: Place }>(`/trips/${tripId}/places/${id}/image`, fd)
|
|
},
|
|
rate: (tripId: number | string, id: number | string, rating: number | null): Promise<{ place: Place }> =>
|
|
rating === null
|
|
? apiClient.delete(`/trips/${tripId}/places/${id}/rating`).then(r => r.data)
|
|
: apiClient.put(`/trips/${tripId}/places/${id}/rating`, { rating }).then(r => r.data),
|
|
importGpx: (tripId: number | string, file: File, opts?: { waypoints?: boolean; routes?: boolean; tracks?: boolean }) => {
|
|
const fd = new FormData()
|
|
fd.append('file', file)
|
|
if (opts?.waypoints !== undefined) fd.append('importWaypoints', String(opts.waypoints))
|
|
if (opts?.routes !== undefined) fd.append('importRoutes', String(opts.routes))
|
|
if (opts?.tracks !== undefined) fd.append('importTracks', String(opts.tracks))
|
|
return postMultipart(`/trips/${tripId}/places/import/gpx`, fd)
|
|
},
|
|
importMapFile: (tripId: number | string, file: File, opts?: { points?: boolean; paths?: boolean }) => {
|
|
const fd = new FormData()
|
|
fd.append('file', file)
|
|
if (opts?.points !== undefined) fd.append('importPoints', String(opts.points))
|
|
if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths))
|
|
return postMultipart(`/trips/${tripId}/places/import/map`, fd)
|
|
},
|
|
importGoogleList: (tripId: number | string, url: string, enrich?: boolean) =>
|
|
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
|
|
importNaverList: (tripId: number | string, url: string, enrich?: boolean) =>
|
|
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
|
|
bulkDelete: (tripId: number | string, ids: number[]) =>
|
|
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids } satisfies PlaceBulkDeleteRequest).then(r => r.data),
|
|
bulkUpdate: (tripId: number | string, ids: number[], data: Omit<PlaceBulkUpdateRequest, 'ids'>) =>
|
|
apiClient.post(`/trips/${tripId}/places/bulk-update`, { ids, ...data } satisfies PlaceBulkUpdateRequest).then(r => r.data),
|
|
}
|
|
|
|
export const assignmentsApi = {
|
|
list: (tripId: number | string, dayId: number | string) => apiClient.get(`/trips/${tripId}/days/${dayId}/assignments`).then(r => r.data),
|
|
create: (tripId: number | string, dayId: number | string, data: AssignmentCreateRequest) => apiClient.post(`/trips/${tripId}/days/${dayId}/assignments`, data).then(r => r.data),
|
|
delete: (tripId: number | string, dayId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/days/${dayId}/assignments/${id}`).then(r => r.data),
|
|
reorder: (tripId: number | string, dayId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/reorder`, { orderedIds } satisfies AssignmentReorderRequest).then(r => r.data),
|
|
move: (tripId: number | string, assignmentId: number, newDayId: number | string, orderIndex: number | null) => apiClient.put(`/trips/${tripId}/assignments/${assignmentId}/move`, { new_day_id: newDayId, order_index: orderIndex }).then(r => r.data),
|
|
update: (tripId: number | string, dayId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/${id}`, data).then(r => r.data),
|
|
getParticipants: (tripId: number | string, id: number) => apiClient.get(`/trips/${tripId}/assignments/${id}/participants`).then(r => r.data),
|
|
setParticipants: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds } satisfies AssignmentParticipantsRequest).then(r => r.data),
|
|
updateTime: (tripId: number | string, id: number, times: AssignmentTimeRequest) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data),
|
|
// Per-segment travel mode (#1281): mode of the leg leaving this stop (null = inherit day default).
|
|
updateTransport: (tripId: number | string, id: number, mode: string | null, direction: 'outgoing' | 'incoming' = 'outgoing') => apiClient.put(`/trips/${tripId}/assignments/${id}/transport`, { transport_mode: mode, direction } satisfies AssignmentTransportRequest).then(r => r.data),
|
|
}
|
|
|
|
export const packingApi = {
|
|
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing`).then(r => r.data),
|
|
create: (tripId: number | string, data: PackingCreateItemRequest) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data),
|
|
bulkImport: (tripId: number | string, items: { name: string; category?: string; quantity?: number }[]) => apiClient.post(`/trips/${tripId}/packing/import`, { items } satisfies PackingImportRequest).then(r => r.data),
|
|
update: (tripId: number | string, id: number, data: PackingUpdateItemRequest) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data),
|
|
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data),
|
|
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds } satisfies PackingReorderRequest).then(r => r.data),
|
|
setSharing: (tripId: number | string, id: number, data: PackingSetSharingRequest) => apiClient.put(`/trips/${tripId}/packing/${id}/sharing`, data).then(r => r.data),
|
|
clone: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/packing/${id}/clone`).then(r => r.data),
|
|
addContributor: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/packing/${id}/contributors`).then(r => r.data),
|
|
removeContributor: (tripId: number | string, id: number, userId: number) => apiClient.delete(`/trips/${tripId}/packing/${id}/contributors/${userId}`).then(r => r.data),
|
|
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/category-assignees`).then(r => r.data),
|
|
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds } satisfies PackingCategoryAssigneesRequest).then(r => r.data),
|
|
listTemplates: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/templates`).then(r => r.data),
|
|
applyTemplate: (tripId: number | string, templateId: number, visibility: 'common' | 'personal' = 'common') => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`, { visibility } satisfies PackingApplyTemplateRequest).then(r => r.data),
|
|
saveAsTemplate: (tripId: number | string, name: string) => apiClient.post(`/trips/${tripId}/packing/save-as-template`, { name }).then(r => r.data),
|
|
setBagMembers: (tripId: number | string, bagId: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}/members`, { user_ids: userIds } satisfies PackingBagMembersRequest).then(r => r.data),
|
|
listBags: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/bags`).then(r => r.data),
|
|
createBag: (tripId: number | string, data: PackingCreateBagRequest) => apiClient.post(`/trips/${tripId}/packing/bags`, data).then(r => r.data),
|
|
updateBag: (tripId: number | string, bagId: number, data: PackingUpdateBagRequest) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}`, data).then(r => r.data),
|
|
deleteBag: (tripId: number | string, bagId: number) => apiClient.delete(`/trips/${tripId}/packing/bags/${bagId}`).then(r => r.data),
|
|
}
|
|
|
|
export const todoApi = {
|
|
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/todo`).then(r => r.data),
|
|
create: (tripId: number | string, data: TodoCreateItemRequest) => apiClient.post(`/trips/${tripId}/todo`, data).then(r => r.data),
|
|
update: (tripId: number | string, id: number, data: TodoUpdateItemRequest) => apiClient.put(`/trips/${tripId}/todo/${id}`, data).then(r => r.data),
|
|
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/todo/${id}`).then(r => r.data),
|
|
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/todo/reorder`, { orderedIds } satisfies TodoReorderRequest).then(r => r.data),
|
|
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/todo/category-assignees`).then(r => r.data),
|
|
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/todo/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds } satisfies TodoCategoryAssigneesRequest).then(r => r.data),
|
|
}
|
|
|
|
export const tagsApi = {
|
|
list: () => apiClient.get('/tags').then(r => r.data),
|
|
create: (data: CreateTagRequest) => apiClient.post('/tags', data).then(r => r.data),
|
|
update: (id: number, data: UpdateTagRequest) => apiClient.put(`/tags/${id}`, data).then(r => r.data),
|
|
delete: (id: number) => apiClient.delete(`/tags/${id}`).then(r => r.data),
|
|
}
|
|
|
|
export const categoriesApi = {
|
|
list: () => apiClient.get('/categories').then(r => r.data),
|
|
create: (data: CreateCategoryRequest) => apiClient.post('/categories', data).then(r => r.data),
|
|
update: (id: number, data: UpdateCategoryRequest) => apiClient.put(`/categories/${id}`, data).then(r => r.data),
|
|
delete: (id: number) => apiClient.delete(`/categories/${id}`).then(r => r.data),
|
|
}
|
|
|
|
export const adminApi = {
|
|
users: () => apiClient.get('/admin/users').then(r => r.data),
|
|
createUser: (data: Record<string, unknown>) => apiClient.post('/admin/users', data).then(r => r.data),
|
|
updateUser: (id: number, data: Record<string, unknown>) => apiClient.put(`/admin/users/${id}`, data).then(r => r.data),
|
|
deleteUser: (id: number) => apiClient.delete(`/admin/users/${id}`).then(r => r.data),
|
|
resetUserPasskeys: (id: number) => apiClient.delete(`/admin/users/${id}/passkeys`).then(r => r.data),
|
|
stats: () => apiClient.get('/admin/stats').then(r => r.data),
|
|
saveDemoBaseline: () => apiClient.post('/admin/save-demo-baseline').then(r => r.data),
|
|
getOidc: () => apiClient.get('/admin/oidc').then(r => r.data),
|
|
updateOidc: (data: Record<string, unknown>) => apiClient.put('/admin/oidc', data).then(r => r.data),
|
|
addons: () => apiClient.get('/admin/addons').then(r => r.data),
|
|
updateAddon: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data),
|
|
plugins: () => apiClient.get('/admin/plugins').then(r => r.data),
|
|
pluginBrowse: (refresh?: boolean) => apiClient.get('/admin/plugins/registry', { params: refresh ? { refresh: 1 } : undefined }).then(r => r.data),
|
|
pluginDetail: (id: string) => apiClient.get(`/admin/plugins/registry/${encodeURIComponent(id)}`).then(r => r.data),
|
|
pluginInstall: (id: string, opts?: { version?: string; constraint?: string; withDependencies?: boolean }) =>
|
|
apiClient.post('/admin/plugins/install', { id, ...opts }).then(r => r.data),
|
|
pluginActivate: (id: string, consent?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, consent ? { consent: true } : {}).then(r => r.data),
|
|
pluginDeactivate: (id: string) => apiClient.post(`/admin/plugins/${id}/deactivate`).then(r => r.data),
|
|
pluginUpdate: (id: string) => apiClient.post(`/admin/plugins/${id}/update`).then(r => r.data),
|
|
// Re-trust a ROTATED author signing key and update, in ONE call. `publicKey` is the
|
|
// full key the admin was shown (not a fingerprint): the server compares it exactly, so
|
|
// it can refuse if the registry entry was re-keyed again since the dialog rendered.
|
|
pluginRetrust: (id: string, version: string, publicKey: string) =>
|
|
apiClient.post(`/admin/plugins/${id}/retrust`, { version, publicKey }).then(r => r.data),
|
|
pluginUninstall: (id: string, deleteData: boolean) => apiClient.post(`/admin/plugins/${id}/uninstall`, { deleteData }).then(r => r.data),
|
|
pluginRescan: () => apiClient.post('/admin/plugins/rescan').then(r => r.data),
|
|
pluginUpload: (file: File) => { const fd = new FormData(); fd.append('file', file); return postMultipart('/admin/plugins/upload', fd) },
|
|
// Dev-link (dev-only): register a plugin from a local built dir + hot-reload it.
|
|
pluginLink: (path: string) => apiClient.post('/admin/plugins/link', { path }).then(r => r.data),
|
|
pluginReload: (id: string) => apiClient.post(`/admin/plugins/${id}/reload`).then(r => r.data),
|
|
// Operator-supplied egress hosts: a plugin talking to a SELF-HOSTED service can't name
|
|
// the operator's hostname in its manifest, so the admin adds it here. Saving re-spawns
|
|
// the plugin with the widened allow-list.
|
|
pluginEgressHosts: (id: string): Promise<{ supported: boolean; hosts: string[] }> =>
|
|
apiClient.get(`/admin/plugins/${id}/egress-hosts`).then(r => r.data),
|
|
pluginSetEgressHosts: (id: string, hosts: string[]): Promise<{ hosts: string[] }> =>
|
|
apiClient.put(`/admin/plugins/${id}/egress-hosts`, { hosts }).then(r => r.data),
|
|
pluginErrors: (id: string) => apiClient.get(`/admin/plugins/${id}/errors`).then(r => r.data),
|
|
pluginAudit: (id: string) => apiClient.get(`/admin/plugins/${id}/audit`).then(r => r.data),
|
|
// Local LLM (Ollama) management for the AI-parsing addon.
|
|
llmLocalModels: (baseUrl: string): Promise<{ models: { name: string; size: number }[] }> =>
|
|
apiClient.get('/admin/llm/local/models', { params: { baseUrl } }).then(r => r.data),
|
|
/** Pull a model, streaming Ollama's NDJSON progress to `onProgress`. */
|
|
llmLocalPull: async (
|
|
baseUrl: string,
|
|
model: string,
|
|
onProgress: (p: { status?: string; total?: number; completed?: number; error?: string }) => void,
|
|
): Promise<void> => {
|
|
const res = await fetch('/api/admin/llm/local/pull', {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ baseUrl, model }),
|
|
})
|
|
if (!res.ok) {
|
|
let msg = `Pull failed (${res.status})`
|
|
try { msg = (await res.json())?.error ?? msg } catch { /* non-json */ }
|
|
throw new Error(msg)
|
|
}
|
|
// An accepted request without a stream can't be followed to completion.
|
|
if (!res.body) throw new Error('Pull returned no progress stream')
|
|
const reader = res.body.getReader()
|
|
const dec = new TextDecoder()
|
|
let buf = ''
|
|
try {
|
|
for (;;) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
buf += dec.decode(value, { stream: true })
|
|
// split() always yields at least one element; the last one is the
|
|
// trailing (possibly partial) line carried into the next chunk.
|
|
const lines = buf.split('\n')
|
|
buf = lines.pop()!
|
|
for (const line of lines) {
|
|
if (!line.trim()) continue
|
|
// Only the parse is swallowed — a throw from onProgress aborts the pull.
|
|
let frame: { status?: string; total?: number; completed?: number; error?: string }
|
|
try { frame = JSON.parse(line) } catch { continue }
|
|
onProgress(frame)
|
|
}
|
|
}
|
|
} finally {
|
|
reader.cancel().catch(() => {})
|
|
}
|
|
},
|
|
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
|
|
getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data),
|
|
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
|
|
getPlacesPhotos: () => apiClient.get('/admin/places-photos').then(r => r.data),
|
|
updatePlacesPhotos: (enabled: boolean) => apiClient.put('/admin/places-photos', { enabled }).then(r => r.data),
|
|
getPlacesAutocomplete: () => apiClient.get('/admin/places-autocomplete').then(r => r.data),
|
|
updatePlacesAutocomplete: (enabled: boolean) => apiClient.put('/admin/places-autocomplete', { enabled }).then(r => r.data),
|
|
getPlacesDetails: () => apiClient.get('/admin/places-details').then(r => r.data),
|
|
updatePlacesDetails: (enabled: boolean) => apiClient.put('/admin/places-details', { enabled }).then(r => r.data),
|
|
getPlacesEnrich: () => apiClient.get('/admin/places-enrich').then(r => r.data),
|
|
updatePlacesEnrich: (enabled: boolean) => apiClient.put('/admin/places-enrich', { enabled }).then(r => r.data),
|
|
getCollabFeatures: () => apiClient.get('/admin/collab-features').then(r => r.data),
|
|
updateCollabFeatures: (features: Record<string, boolean>) => apiClient.put('/admin/collab-features', features).then(r => r.data),
|
|
packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data),
|
|
getPackingTemplate: (id: number) => apiClient.get(`/admin/packing-templates/${id}`).then(r => r.data),
|
|
createPackingTemplate: (data: { name: string }) => apiClient.post('/admin/packing-templates', data).then(r => r.data),
|
|
updatePackingTemplate: (id: number, data: { name: string }) => apiClient.put(`/admin/packing-templates/${id}`, data).then(r => r.data),
|
|
deletePackingTemplate: (id: number) => apiClient.delete(`/admin/packing-templates/${id}`).then(r => r.data),
|
|
addTemplateCategory: (templateId: number, data: { name: string }) => apiClient.post(`/admin/packing-templates/${templateId}/categories`, data).then(r => r.data),
|
|
updateTemplateCategory: (templateId: number, catId: number, data: { name: string }) => apiClient.put(`/admin/packing-templates/${templateId}/categories/${catId}`, data).then(r => r.data),
|
|
deleteTemplateCategory: (templateId: number, catId: number) => apiClient.delete(`/admin/packing-templates/${templateId}/categories/${catId}`).then(r => r.data),
|
|
addTemplateItem: (templateId: number, catId: number, data: { name: string }) => apiClient.post(`/admin/packing-templates/${templateId}/categories/${catId}/items`, data).then(r => r.data),
|
|
updateTemplateItem: (templateId: number, itemId: number, data: { name: string }) => apiClient.put(`/admin/packing-templates/${templateId}/items/${itemId}`, data).then(r => r.data),
|
|
deleteTemplateItem: (templateId: number, itemId: number) => apiClient.delete(`/admin/packing-templates/${templateId}/items/${itemId}`).then(r => r.data),
|
|
listInvites: () => apiClient.get('/admin/invites').then(r => r.data),
|
|
listInviteTrips: () => apiClient.get('/admin/invites/trips').then(r => r.data),
|
|
createInvite: (data: { max_uses: number; expires_in_days?: number; trip_id?: number | null }) => apiClient.post('/admin/invites', data).then(r => r.data),
|
|
deleteInvite: (id: number) => apiClient.delete(`/admin/invites/${id}`).then(r => r.data),
|
|
auditLog: (params?: { limit?: number; offset?: number }) =>
|
|
apiClient.get('/admin/audit-log', { params }).then(r => r.data),
|
|
mcpTokens: () => apiClient.get('/admin/mcp-tokens').then(r => r.data),
|
|
deleteMcpToken: (id: number) => apiClient.delete(`/admin/mcp-tokens/${id}`).then(r => r.data),
|
|
oauthSessions: () => apiClient.get('/admin/oauth-sessions').then(r => r.data),
|
|
revokeOAuthSession: (id: number) => apiClient.delete(`/admin/oauth-sessions/${id}`).then(r => r.data),
|
|
getPermissions: () => apiClient.get('/admin/permissions').then(r => r.data),
|
|
updatePermissions: (permissions: Record<string, string>) => apiClient.put('/admin/permissions', { permissions }).then(r => r.data),
|
|
rotateJwtSecret: () => apiClient.post('/admin/rotate-jwt-secret').then(r => r.data),
|
|
sendTestNotification: (data: Record<string, unknown>) =>
|
|
apiClient.post('/admin/dev/test-notification', data).then(r => r.data),
|
|
getNotificationPreferences: () => apiClient.get('/admin/notification-preferences').then(r => r.data),
|
|
updateNotificationPreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/admin/notification-preferences', prefs).then(r => r.data),
|
|
getDefaultUserSettings: () => apiClient.get('/admin/default-user-settings').then(r => r.data),
|
|
updateDefaultUserSettings: (settings: Record<string, unknown>) => apiClient.put('/admin/default-user-settings', settings).then(r => r.data),
|
|
}
|
|
|
|
export const addonsApi = {
|
|
enabled: () => apiClient.get('/addons').then(r => r.data),
|
|
}
|
|
|
|
/** A host-rendered column/action a plugin contributes into a native planner view
|
|
* (reservations/places/day) via the tableContributor hook. Every field is bounded +
|
|
* normalized server-side; a column url is guaranteed http/https/mailto. */
|
|
export type ViewContribution =
|
|
| { kind: 'column'; pluginId: string; entityId: number; id: string; label: string; value?: string; url?: string; icon?: string; tone: 'default' | 'success' | 'warn' | 'danger' }
|
|
| { kind: 'action'; pluginId: string; entityId: number; id: string; label: string; icon?: string; target: { kind: 'frame'; sub: string } | { kind: 'route'; method: 'GET' | 'POST'; sub: string } }
|
|
|
|
/** A badge a plugin adds to a dashboard trip card via the tripCardProvider hook.
|
|
* Bounded + normalized server-side; the url is guaranteed http/https/mailto. */
|
|
export interface TripCardBadge {
|
|
pluginId: string; tripId: number; id: string; label: string;
|
|
value?: string; icon?: string; tone: 'default' | 'success' | 'warn' | 'danger'; url?: string;
|
|
}
|
|
|
|
export interface PluginMapMarker {
|
|
pluginId: string; id: string; lat: number; lng: number;
|
|
label?: string; popupText?: string; url?: string; icon?: string;
|
|
tone: 'default' | 'success' | 'warn' | 'danger'
|
|
}
|
|
|
|
/** One shape of a plugin map layer (mapLayerProvider hook). Server-normalized:
|
|
* coordinates range-checked, vertex budget capped, styling clamped to the tone
|
|
* palette + bounded numerics — never free-form CSS or markup. */
|
|
export interface PluginMapLayerFeature {
|
|
type: 'polyline' | 'polygon' | 'circle';
|
|
points?: Array<[number, number]>;
|
|
center?: [number, number];
|
|
radiusM?: number;
|
|
tone: 'default' | 'success' | 'warn' | 'danger';
|
|
width: number;
|
|
dash: 'solid' | 'dash' | 'dot';
|
|
opacity: number;
|
|
fill: boolean;
|
|
label?: string;
|
|
}
|
|
|
|
/** A vector overlay a plugin draws on the trip map (routes, corridors, zones). */
|
|
export interface PluginMapLayer {
|
|
pluginId: string; id: string; name?: string;
|
|
features: PluginMapLayerFeature[];
|
|
}
|
|
|
|
/** A time contribution a dayScheduleProvider plugin attaches to the day plan
|
|
* ("35 min charging at this stop"). Server-normalized: dayIds checked against
|
|
* the trip, minutes clamped to a day, labels sanitized + capped. */
|
|
export interface PluginDayScheduleItem {
|
|
pluginId: string; id: string; dayId: number;
|
|
assignmentId?: number; reservationId?: number;
|
|
position?: 'start' | 'end';
|
|
minutes?: number; label: string;
|
|
tone: 'default' | 'success' | 'warn' | 'danger';
|
|
}
|
|
|
|
/** The colours a dayTintProvider plugin puts into one day card, so leg membership is
|
|
* visible while scrolling the itinerary. The card has three separately tintable
|
|
* regions; an absent one is not tinted and renders exactly as it does with no plugin.
|
|
* Server-normalized: dayIds checked against the trip, one contribution per day (first
|
|
* granted provider wins), the `tone` / `color` shorthands already resolved into the
|
|
* regions, labels sanitized + capped.
|
|
*
|
|
* A region carries EITHER a tone from the fixed palette or the plugin's own colour,
|
|
* never both — the server picked the winner. `*Color` is guaranteed `#rrggbb` (nothing
|
|
* else survives normalization, because it lands inside a CSS value); the client still
|
|
* owns how strongly it renders — alpha per theme and per region, lightness clamped
|
|
* into a readable band. */
|
|
export type PluginDayTintTone = 'default' | 'success' | 'warn' | 'danger'
|
|
export interface PluginDayTint {
|
|
pluginId: string; dayId: number;
|
|
badgeTone?: PluginDayTintTone;
|
|
badgeColor?: string;
|
|
headerTone?: PluginDayTintTone;
|
|
headerColor?: string;
|
|
activityTone?: PluginDayTintTone;
|
|
activityColor?: string;
|
|
label?: string;
|
|
}
|
|
|
|
/** A route computed by a routeProvider plugin (server-normalized: coordinates
|
|
* range-checked, legs forced to waypoints-1, vias capped). null = provider failed
|
|
* or refused — the caller falls back to straight lines like on an OSRM outage. */
|
|
export interface PluginRouteResult {
|
|
pluginId: string; profile: string;
|
|
coordinates: Array<[number, number]>;
|
|
distance: number; duration: number;
|
|
legs: Array<{ distance: number; duration: number; note?: string }>;
|
|
viaPoints: Array<{ lat: number; lng: number; label?: string; tone: 'default' | 'success' | 'warn' | 'danger'; dwellSeconds?: number }>;
|
|
}
|
|
|
|
/** A text-only section a pdfSectionProvider plugin appends to the trip PDF export.
|
|
* Server-normalized: counts + lengths are capped, cells are plain strings. */
|
|
export interface PluginPdfSection {
|
|
pluginId: string; title: string; paragraphs: string[];
|
|
table?: { headers: string[]; rows: string[][] }
|
|
}
|
|
|
|
/** A country tint layer an atlasLayerProvider plugin draws over the Atlas map for
|
|
* the signed-in user. Codes are ISO alpha-2 (server-validated), tone enum-whitelisted. */
|
|
export interface PluginAtlasLayer {
|
|
pluginId: string; id: string; name?: string;
|
|
countries: Array<{ code: string; tone: 'default' | 'success' | 'warn' | 'danger'; label?: string }>
|
|
}
|
|
|
|
export interface PluginUserSettingField {
|
|
key: string; label?: string | null; input_type?: string; placeholder?: string | null;
|
|
hint?: string | null; required?: boolean; secret?: boolean;
|
|
options?: Array<{ value: string; label: string }>
|
|
}
|
|
|
|
/** A button a plugin contributes to its own settings page ("Test connection"). */
|
|
export interface PluginAction {
|
|
key: string; label: string; hint?: string; danger: boolean
|
|
}
|
|
|
|
export const pluginsApi = {
|
|
// Active plugins the client renders (page nav entries, dashboard widgets).
|
|
active: () => apiClient.get('/plugins').then(r => r.data),
|
|
// Extra place info contributed by placeDetailProvider plugins (#1429). Fail-safe:
|
|
// the server skips any slow/failing provider, so this only ever adds rows.
|
|
placeDetails: (placeId: number) =>
|
|
apiClient.get(`/place-details/${placeId}`).then(r => r.data as { providers: Array<{ pluginId: string; items: Array<{ label: string; value?: string; url?: string }> }> }),
|
|
// Validation/warning contributions from warningProvider plugins (#1429). Fail-safe.
|
|
tripWarnings: (tripId: number) =>
|
|
apiClient.get(`/trip-warnings/${tripId}`).then(r => r.data as { warnings: Array<{ pluginId: string; level: 'info' | 'warning' | 'error'; message: string; dayId?: number; placeId?: number }> }),
|
|
// Host-rendered columns/actions plugins add into a native planner view via the
|
|
// tableContributor hook. Fetched once per view, keyed by entityId; fail-safe.
|
|
viewContributions: (view: 'reservations' | 'transports' | 'places' | 'day' | 'costs' | 'packing' | 'files' | 'todos', tripId: number | string) =>
|
|
apiClient.get(`/view-contributions/${view}/${tripId}`).then(r => r.data as { contributions: ViewContribution[] }),
|
|
// Bounded markers plugins overlay on the trip map via the mapMarkerProvider hook
|
|
// (#587). Host-normalized + range-checked; fail-safe (skips slow/failing providers).
|
|
mapMarkers: (tripId: number | string) =>
|
|
apiClient.get(`/map-markers/${tripId}`).then(r => r.data as { markers: PluginMapMarker[] }),
|
|
// Vector overlays (polylines/polygons/circles) plugins draw on the trip map via
|
|
// the mapLayerProvider hook. Host-normalized + vertex-budgeted; fail-safe.
|
|
mapLayers: (tripId: number | string) =>
|
|
apiClient.get(`/map-layers/${tripId}`).then(r => r.data as { layers: PluginMapLayer[] }),
|
|
// Route the given waypoints through ONE routeProvider plugin profile (targeted,
|
|
// not a fan-out — the user picked this profile in the route toggle). Slow by
|
|
// design (external solvers): the server allows the plugin 20 s.
|
|
pluginRoute: (pluginId: string, profileId: string, body: { tripId: number | string; dayId?: number | null; waypoints: Array<{ lat: number; lng: number; name?: string; placeId?: number }> }, opts: { signal?: AbortSignal } = {}) =>
|
|
apiClient.post(`/plugin-routes/${pluginId}/${profileId}`, body, { timeout: 25000, signal: opts.signal }).then(r => r.data as { route: PluginRouteResult | null }),
|
|
// Time contributions plugins attach to the day plan via the dayScheduleProvider
|
|
// hook (charging stops, security buffers). Host-normalized; fail-safe.
|
|
daySchedule: (tripId: number | string) =>
|
|
apiClient.get(`/day-schedule/${tripId}`).then(r => r.data as { items: PluginDayScheduleItem[] }),
|
|
// Per-day colours plugins put behind the day cards via the dayTintProvider hook
|
|
// (which leg of the trip a day belongs to). Host-normalized; fail-safe.
|
|
dayTints: (tripId: number | string) =>
|
|
apiClient.get(`/day-tints/${tripId}`).then(r => r.data as { tints: PluginDayTint[] }),
|
|
// Text-only sections plugins append to the trip PDF export via the
|
|
// pdfSectionProvider hook. Host-normalized (counts + lengths capped); fail-safe.
|
|
pdfSections: (tripId: number | string) =>
|
|
apiClient.get(`/pdf-sections/${tripId}`).then(r => r.data as { sections: PluginPdfSection[] }),
|
|
// Country tint layers plugins draw over the Atlas map for the signed-in user via
|
|
// the atlasLayerProvider hook. No tripId — user-scoped server-side; fail-safe.
|
|
atlasLayers: () =>
|
|
apiClient.get('/atlas-layers').then(r => r.data as { layers: PluginAtlasLayer[] }),
|
|
// Extra rows plugins add under a journal entry via the journalEntryProvider hook.
|
|
// Same shape + hardening as placeDetails (label/value/allowlisted url); fail-safe.
|
|
journalEntryRows: (entryId: number) =>
|
|
apiClient.get(`/journal-entry-rows/${entryId}`).then(r => r.data as { providers: Array<{ pluginId: string; items: Array<{ label: string; value?: string; url?: string }> }> }),
|
|
// Badges plugins add to the dashboard trip cards via the tripCardProvider hook.
|
|
// One call for all visible cards; host access-checks each tripId + bounds every
|
|
// field (label/value/tone/allowlisted url); fail-safe.
|
|
tripCardContributions: (tripIds: Array<number | string>) =>
|
|
apiClient.get(`/trip-card-contributions?tripIds=${tripIds.join(',')}`).then(r => r.data as { contributions: TripCardBadge[] }),
|
|
// The signed-in user's OWN plugin activity log — every host-mediated action a
|
|
// plugin took bound to them, across all plugins, newest first. The user-facing
|
|
// half of the capability audit; what makes the broad read grants accountable.
|
|
myActivity: (limit = 200) =>
|
|
apiClient.get(`/plugin-activity?limit=${limit}`).then(r => r.data as { activity: Array<{ ts: string; plugin_id: string; plugin_name: string | null; method: string; resource: string | null; code: string }> }),
|
|
// A user's OWN scope:'user' settings for a plugin (API key, prefs). Secrets are
|
|
// masked; the write only accepts declared user-scope keys.
|
|
userSettings: (id: string) =>
|
|
apiClient.get(`/plugin-settings/${id}`).then(r => r.data as {
|
|
fields: PluginUserSettingField[]
|
|
config: Record<string, unknown>
|
|
actions: PluginAction[]
|
|
}),
|
|
// Run a settings-page action the plugin declared ("Test connection"). It runs AS the
|
|
// caller, so it reads the caller's own settings.
|
|
runAction: (id: string, key: string) =>
|
|
apiClient.post(`/plugin-settings/${id}/actions/${encodeURIComponent(key)}`)
|
|
.then(r => r.data as { ok: boolean; message?: string }),
|
|
saveUserSettings: (id: string, config: Record<string, unknown>) =>
|
|
apiClient.post(`/plugin-settings/${id}`, { config }).then(r => r.data as { config: Record<string, unknown> }),
|
|
// Host-brokered outbound OAuth (the host owns the tokens; the plugin only triggers).
|
|
oauthStatus: (id: string) =>
|
|
apiClient.get(`/plugin-oauth/${id}/status`).then(r => r.data as { configured: boolean; connected: boolean }),
|
|
oauthConnect: (id: string) =>
|
|
apiClient.post(`/plugin-oauth/${id}/connect`).then(r => r.data as { authorizeUrl: string }),
|
|
oauthDisconnect: (id: string) =>
|
|
apiClient.post(`/plugin-oauth/${id}/disconnect`).then(r => r.data as { connected: boolean }),
|
|
// Call one of a plugin's own declared routes through the host proxy. `sub` is
|
|
// supplied by untrusted plugin code (the trekBridge forwards it verbatim), so it
|
|
// MUST stay inside the plugin's own /plugins/:id/ namespace. We resolve it with
|
|
// the URL parser — which normalizes `../`, encoded traversal and backslashes the
|
|
// same way the browser would before sending — and reject anything that escapes
|
|
// the prefix or points off-origin. Without this a plugin could send
|
|
// sub='/../../auth/me' and drive arbitrary authenticated /api routes as the user.
|
|
invoke: (id: string, sub: string, init?: { method?: string; body?: unknown }) => {
|
|
const prefix = `/api/plugins/${id}/`
|
|
let resolved: URL
|
|
try {
|
|
resolved = new URL(String(sub).replace(/^\/+/, ''), window.location.origin + prefix)
|
|
} catch {
|
|
return Promise.reject(new Error('invalid plugin route'))
|
|
}
|
|
if (resolved.origin !== window.location.origin || !resolved.pathname.startsWith(prefix)) {
|
|
return Promise.reject(new Error('plugin route escapes its namespace'))
|
|
}
|
|
const url = resolved.pathname.slice('/api'.length) + resolved.search
|
|
return apiClient.request({ url, method: init?.method || 'GET', data: init?.body }).then(r => r.data)
|
|
},
|
|
}
|
|
|
|
export const airtrailApi = {
|
|
getSettings: () => apiClient.get('/integrations/airtrail/settings').then(r => r.data),
|
|
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 }) =>
|
|
apiClient.post('/integrations/airtrail/test', data).then(r => r.data),
|
|
sync: (): Promise<{ changed: number }> => apiClient.post('/integrations/airtrail/sync').then(r => r.data),
|
|
// flights + import are added with the trip-planner import (P2)
|
|
flights: () => apiClient.get('/integrations/airtrail/flights').then(r => r.data),
|
|
import: (tripId: number, flightIds: string[], connections?: string[][]) =>
|
|
apiClient.post(`/trips/${tripId}/reservations/import/airtrail`, connections?.length ? { flightIds, connections } : { flightIds }).then(r => r.data),
|
|
}
|
|
|
|
export const journeyApi = {
|
|
list: () => apiClient.get('/journeys').then(r => r.data),
|
|
create: (data: JourneyCreateRequest) => apiClient.post('/journeys', data).then(r => r.data),
|
|
get: (id: number) => apiClient.get(`/journeys/${id}`).then(r => r.data),
|
|
update: (id: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/${id}`, data).then(r => r.data),
|
|
delete: (id: number) => apiClient.delete(`/journeys/${id}`).then(r => r.data),
|
|
|
|
suggestions: () => apiClient.get('/journeys/suggestions').then(r => r.data),
|
|
availableTrips: () => apiClient.get('/journeys/available-trips').then(r => r.data),
|
|
|
|
// Trips (sync sources)
|
|
addTrip: (id: number, tripId: number) => apiClient.post(`/journeys/${id}/trips`, { trip_id: tripId } satisfies JourneyAddTripRequest).then(r => r.data),
|
|
removeTrip: (id: number, tripId: number) => apiClient.delete(`/journeys/${id}/trips/${tripId}`).then(r => r.data),
|
|
|
|
// Entries
|
|
listEntries: (id: number) => apiClient.get(`/journeys/${id}/entries`).then(r => r.data),
|
|
createEntry: (id: number, data: Record<string, unknown>) => apiClient.post(`/journeys/${id}/entries`, data).then(r => r.data),
|
|
updateEntry: (entryId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/entries/${entryId}`, data).then(r => r.data),
|
|
deleteEntry: (entryId: number) => apiClient.delete(`/journeys/entries/${entryId}`).then(r => r.data),
|
|
reorderEntries: (journeyId: number, orderedIds: number[]) => apiClient.put(`/journeys/${journeyId}/entries/reorder`, { orderedIds } satisfies JourneyReorderEntriesRequest).then(r => r.data),
|
|
|
|
// Photos
|
|
uploadPhotos: (entryId: number, formData: FormData, opts?: UploadOptions) =>
|
|
postMultipart(`/journeys/entries/${entryId}/photos`, formData, opts),
|
|
uploadGalleryPhotos: (journeyId: number, formData: FormData, opts?: UploadOptions) =>
|
|
postMultipart(`/journeys/${journeyId}/gallery/photos`, formData, opts),
|
|
uploadGalleryVideo: (journeyId: number, formData: FormData, opts?: UploadOptions) =>
|
|
postMultipart(`/journeys/${journeyId}/gallery/video`, formData, opts),
|
|
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string, mediaTypes?: string[]) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}), ...(mediaTypes ? { media_types: mediaTypes } : {}) } satisfies JourneyProviderPhotosRequest).then(r => r.data),
|
|
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
|
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string, mediaTypes?: string[]) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}), ...(mediaTypes ? { media_types: mediaTypes } : {}) }).then(r => r.data),
|
|
linkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { journey_photo_id: journeyPhotoId }).then(r => r.data),
|
|
unlinkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/entries/${entryId}/photos/${journeyPhotoId}`).then(r => r.data),
|
|
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/${journeyId}/gallery/${journeyPhotoId}`).then(r => r.data),
|
|
updatePhoto: (photoId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/photos/${photoId}`, data).then(r => r.data),
|
|
deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data),
|
|
|
|
// Cover
|
|
uploadCover: (id: number, formData: FormData) => postMultipart(`/journeys/${id}/cover`, formData),
|
|
|
|
// Contributors
|
|
addContributor: (id: number, userId: number, role: string) => apiClient.post(`/journeys/${id}/contributors`, { user_id: userId, role }).then(r => r.data),
|
|
updateContributor: (id: number, userId: number, role: string) => apiClient.patch(`/journeys/${id}/contributors/${userId}`, { role }).then(r => r.data),
|
|
removeContributor: (id: number, userId: number) => apiClient.delete(`/journeys/${id}/contributors/${userId}`).then(r => r.data),
|
|
|
|
// Preferences
|
|
updatePreferences: (id: number, data: { hide_skeletons?: boolean }) => apiClient.patch(`/journeys/${id}/preferences`, data).then(r => r.data),
|
|
|
|
// Share
|
|
getShareLink: (id: number) => apiClient.get(`/journeys/${id}/share-link`).then(r => r.data),
|
|
createShareLink: (id: number, perms: JourneyShareLinkRequest) => apiClient.post(`/journeys/${id}/share-link`, perms).then(r => r.data),
|
|
deleteShareLink: (id: number) => apiClient.delete(`/journeys/${id}/share-link`).then(r => r.data),
|
|
getPublicJourney: (token: string) => apiClient.get(`/public/journey/${token}`).then(r => r.data),
|
|
}
|
|
|
|
export const mapsApi = {
|
|
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')),
|
|
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal) =>
|
|
apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')),
|
|
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details')),
|
|
// Pictures and a description for a place that is being looked at but not yet
|
|
// saved. Fans out to several providers server-side, so it takes a signal and
|
|
// the caller is expected to abort it when the selection changes, and a longer
|
|
// timeout than the global 8s — a cold Wikimedia connection alone can eat that.
|
|
placeEnrichment: (
|
|
body: { placeId?: string; lat: number; lng: number; name: string; lang?: string; details?: Record<string, unknown> },
|
|
signal?: AbortSignal,
|
|
) => apiClient.post('/maps/enrichment', body, { signal, timeout: 25000 })
|
|
.then(r => checkInDev(mapsPlaceEnrichmentResultSchema, r.data, 'maps.placeEnrichment')),
|
|
// Author + licence for a picture already stored in the photo cache, keyed by
|
|
// the cache key embedded in its proxy URL. Local read, no provider call.
|
|
placePhotoCredit: (key: string) =>
|
|
apiClient.get(`/maps/enrichment/credit/${encodeURIComponent(key)}`).then(r => r.data as { credit: string | null }),
|
|
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => checkInDev(mapsPlacePhotoResultSchema, r.data, 'maps.placePhoto')),
|
|
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => checkInDev(mapsReverseResultSchema, r.data, 'maps.reverse')),
|
|
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => checkInDev(mapsResolveUrlResultSchema, r.data, 'maps.resolveUrl')),
|
|
// OSM-only POI explore: places of a category within the current map viewport bbox.
|
|
// Overpass can be slow on a fresh (uncached) area, so this call gets a longer
|
|
// timeout than the global default instead of aborting at 8s and showing nothing.
|
|
pois: (category: string, bbox: { south: number; west: number; north: number; east: number }, lang?: string, signal?: AbortSignal) =>
|
|
apiClient.get('/maps/pois', { params: { category, ...bbox, lang }, signal, timeout: 20000 }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean; clamped?: boolean }),
|
|
}
|
|
|
|
export const airportsApi = {
|
|
search: (q: string, signal?: AbortSignal) => apiClient.get('/airports/search', { params: { q }, signal }).then(r => r.data),
|
|
byIata: (iata: string) => apiClient.get(`/airports/${encodeURIComponent(iata)}`).then(r => r.data),
|
|
}
|
|
|
|
export const budgetApi = {
|
|
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget`).then(r => r.data),
|
|
create: (tripId: number | string, data: BudgetCreateItemRequest) => apiClient.post(`/trips/${tripId}/budget`, data).then(r => r.data),
|
|
update: (tripId: number | string, id: number, data: BudgetUpdateItemRequest) => apiClient.put(`/trips/${tripId}/budget/${id}`, data).then(r => r.data),
|
|
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/budget/${id}`).then(r => r.data),
|
|
setMembers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds } satisfies BudgetUpdateMembersRequest).then(r => r.data),
|
|
togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid } satisfies BudgetToggleMemberPaidRequest).then(r => r.data),
|
|
setPayers: (tripId: number | string, id: number, payers: { user_id: number; amount: number }[]) => apiClient.put(`/trips/${tripId}/budget/${id}/payers`, { payers }).then(r => r.data),
|
|
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
|
|
settlement: (tripId: number | string, base?: string) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base } } : undefined).then(r => r.data),
|
|
createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string }) => 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; currency?: string }) => 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),
|
|
}
|
|
|
|
export const filesApi = {
|
|
list: (tripId: number | string, trash?: boolean) => apiClient.get(`/trips/${tripId}/files`, { params: trash ? { trash: 'true' } : {} }).then(r => r.data),
|
|
upload: (tripId: number | string, formData: FormData, opts?: UploadOptions) => postMultipart(`/trips/${tripId}/files`, formData, opts),
|
|
update: (tripId: number | string, id: number, data: FileUpdateRequest) => apiClient.put(`/trips/${tripId}/files/${id}`, data).then(r => r.data),
|
|
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}`).then(r => r.data),
|
|
toggleStar: (tripId: number | string, id: number) => apiClient.patch(`/trips/${tripId}/files/${id}/star`).then(r => r.data),
|
|
restore: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/files/${id}/restore`).then(r => r.data),
|
|
permanentDelete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}/permanent`).then(r => r.data),
|
|
emptyTrash: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/files/trash/empty`).then(r => r.data),
|
|
addLink: (tripId: number | string, fileId: number, data: FileLinkRequest) => apiClient.post(`/trips/${tripId}/files/${fileId}/link`, data).then(r => r.data),
|
|
removeLink: (tripId: number | string, fileId: number, linkId: number) => apiClient.delete(`/trips/${tripId}/files/${fileId}/link/${linkId}`).then(r => r.data),
|
|
getLinks: (tripId: number | string, fileId: number) => apiClient.get(`/trips/${tripId}/files/${fileId}/links`).then(r => r.data),
|
|
}
|
|
|
|
export const reservationsApi = {
|
|
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/reservations`).then(r => r.data),
|
|
upcoming: () => apiClient.get('/reservations/upcoming').then(r => r.data),
|
|
create: (tripId: number | string, data: ReservationCreateRequest) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
|
|
update: (tripId: number | string, id: number, data: ReservationUpdateRequest) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
|
|
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
|
|
// Assign trip members / named guests to a booking (#1517).
|
|
setTravelers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/reservations/${id}/travelers`, { user_ids: userIds }).then(r => r.data),
|
|
updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[], dayId?: number) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions, day_id: dayId }).then(r => r.data),
|
|
importBookingPreview: (tripId: number | string, files: File[], mode: BookingImportMode = 'no-ai'): Promise<BookingImportPreviewResponse> => {
|
|
const fd = new FormData()
|
|
for (const f of files) fd.append('files', f)
|
|
fd.append('mode', mode)
|
|
// No client-side timeout: kitinerary + LLM extraction routinely exceeds the
|
|
// global 8s default (a cold local model alone can take ~45s).
|
|
return postMultipart(`/trips/${tripId}/reservations/import/booking`, fd)
|
|
},
|
|
importBookingConfirm: (tripId: number | string, items: BookingImportPreviewItem[]): Promise<BookingImportConfirmResponse> =>
|
|
apiClient.post(`/trips/${tripId}/reservations/import/booking/confirm`, { items }).then(r => r.data),
|
|
// Start a background parse: returns a job id at once; progress + result arrive
|
|
// over the WebSocket (import:progress / import:done / import:error).
|
|
importBookingAsync: (tripId: number | string, files: File[], mode: BookingImportMode = 'no-ai'): Promise<{ jobId: string }> => {
|
|
const fd = new FormData()
|
|
for (const f of files) fd.append('files', f)
|
|
fd.append('mode', mode)
|
|
return postMultipart(`/trips/${tripId}/reservations/import/booking/async`, fd)
|
|
},
|
|
// Poll a background job — recovery path when a WebSocket push was missed.
|
|
importJobStatus: (tripId: number | string, jobId: string): Promise<{ status: 'running' | 'done' | 'error'; done: number; total: number; result?: BookingImportPreviewResponse; error?: string }> =>
|
|
apiClient.get(`/trips/${tripId}/reservations/import/jobs/${jobId}`).then(r => r.data),
|
|
}
|
|
|
|
export const healthApi = {
|
|
features: (): Promise<{ bookingImport: boolean; aiParsing: boolean }> => apiClient.get('/health/features').then(r => r.data),
|
|
}
|
|
|
|
export const weatherApi = {
|
|
get: (lat: number, lng: number, date: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')),
|
|
getCurrent: (lat: number, lng: number, lang?: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getCurrent')),
|
|
getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise<WeatherResult> => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getDetailed')),
|
|
}
|
|
|
|
export const configApi = {
|
|
getPublicConfig: (): Promise<{ defaultLanguage: string }> =>
|
|
apiClient.get('/config').then(r => r.data),
|
|
}
|
|
|
|
export interface HelpNavItem { title: string; slug: string }
|
|
export interface HelpNavSection { title: string; pages: HelpNavItem[] }
|
|
export interface HelpPageData { slug: string; title: string; markdown: string }
|
|
|
|
export const helpApi = {
|
|
index: (): Promise<{ sections: HelpNavSection[] }> =>
|
|
apiClient.get('/help/index').then(r => r.data),
|
|
page: (slug: string): Promise<HelpPageData> =>
|
|
apiClient.get(`/help/page/${encodeURIComponent(slug)}`).then(r => r.data),
|
|
}
|
|
|
|
export const settingsApi = {
|
|
get: () => apiClient.get('/settings').then(r => r.data),
|
|
set: (key: string, value: unknown) => {
|
|
const body: SettingUpsertRequest = { key, value }
|
|
return apiClient.put('/settings', body).then(r => r.data)
|
|
},
|
|
setBulk: (settings: Record<string, unknown>) => {
|
|
const body: SettingsBulkRequest = { settings }
|
|
return apiClient.post('/settings/bulk', body).then(r => r.data)
|
|
},
|
|
}
|
|
|
|
export const accommodationsApi = {
|
|
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/accommodations`).then(r => r.data),
|
|
create: (tripId: number | string, data: AccommodationCreateRequest) => apiClient.post(`/trips/${tripId}/accommodations`, data).then(r => r.data),
|
|
update: (tripId: number | string, id: number, data: AccommodationUpdateRequest) => apiClient.put(`/trips/${tripId}/accommodations/${id}`, data).then(r => r.data),
|
|
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/accommodations/${id}`).then(r => r.data),
|
|
}
|
|
|
|
export const dayNotesApi = {
|
|
list: (tripId: number | string, dayId: number | string) => apiClient.get(`/trips/${tripId}/days/${dayId}/notes`).then(r => r.data),
|
|
create: (tripId: number | string, dayId: number | string, data: DayNoteCreateRequest) => apiClient.post(`/trips/${tripId}/days/${dayId}/notes`, data).then(r => r.data),
|
|
update: (tripId: number | string, dayId: number | string, id: number, data: DayNoteUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}/notes/${id}`, data).then(r => r.data),
|
|
delete: (tripId: number | string, dayId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/days/${dayId}/notes/${id}`).then(r => r.data),
|
|
}
|
|
|
|
export const collabApi = {
|
|
getNotes: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/notes`).then(r => r.data),
|
|
createNote: (tripId: number | string, data: CollabNoteCreateRequest) => apiClient.post(`/trips/${tripId}/collab/notes`, data).then(r => r.data),
|
|
updateNote: (tripId: number | string, id: number, data: CollabNoteUpdateRequest) => apiClient.put(`/trips/${tripId}/collab/notes/${id}`, data).then(r => r.data),
|
|
deleteNote: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${id}`).then(r => r.data),
|
|
uploadNoteFile: (tripId: number | string, noteId: number, formData: FormData) => postMultipart(`/trips/${tripId}/collab/notes/${noteId}/files`, formData),
|
|
deleteNoteFile: (tripId: number | string, noteId: number, fileId: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${noteId}/files/${fileId}`).then(r => r.data),
|
|
getPolls: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/polls`).then(r => r.data),
|
|
createPoll: (tripId: number | string, data: CollabPollCreateRequest) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data),
|
|
votePoll: (tripId: number | string, id: number, optionIndex: number) => apiClient.post(`/trips/${tripId}/collab/polls/${id}/vote`, { option_index: optionIndex } satisfies CollabPollVoteRequest).then(r => r.data),
|
|
closePoll: (tripId: number | string, id: number) => apiClient.put(`/trips/${tripId}/collab/polls/${id}/close`).then(r => r.data),
|
|
deletePoll: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/polls/${id}`).then(r => r.data),
|
|
getMessages: (tripId: number | string, before?: string) => apiClient.get(`/trips/${tripId}/collab/messages${before ? `?before=${before}` : ''}`).then(r => r.data),
|
|
sendMessage: (tripId: number | string, data: CollabMessageCreateRequest) => apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data),
|
|
deleteMessage: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/messages/${id}`).then(r => r.data),
|
|
reactMessage: (tripId: number | string, id: number, emoji: string) => apiClient.post(`/trips/${tripId}/collab/messages/${id}/react`, { emoji } satisfies CollabReactionRequest).then(r => r.data),
|
|
linkPreview: (tripId: number | string, url: string) => apiClient.get(`/trips/${tripId}/collab/link-preview?url=${encodeURIComponent(url)}`).then(r => r.data),
|
|
}
|
|
|
|
export const backupApi = {
|
|
list: () => apiClient.get('/backup/list').then(r => r.data),
|
|
create: () => apiClient.post('/backup/create').then(r => r.data),
|
|
download: async (filename: string): Promise<void> => {
|
|
const res = await fetch(`/api/backup/download/${filename}`, {
|
|
credentials: 'include',
|
|
})
|
|
if (!res.ok) throw new Error('Download failed')
|
|
const blob = await res.blob()
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = filename
|
|
a.click()
|
|
URL.revokeObjectURL(url)
|
|
},
|
|
delete: (filename: string) => apiClient.delete(`/backup/${filename}`).then(r => r.data),
|
|
restore: (filename: string) => apiClient.post(`/backup/restore/${filename}`).then(r => r.data),
|
|
uploadRestore: (file: File) => {
|
|
const form = new FormData()
|
|
form.append('backup', file)
|
|
return postMultipart('/backup/upload-restore', form)
|
|
},
|
|
getAutoSettings: () => apiClient.get('/backup/auto-settings').then(r => r.data),
|
|
setAutoSettings: (settings: Record<string, unknown>) => apiClient.put('/backup/auto-settings', settings).then(r => r.data),
|
|
}
|
|
|
|
export const shareApi = {
|
|
getLink: (tripId: number | string) => apiClient.get(`/trips/${tripId}/share-link`).then(r => r.data),
|
|
createLink: (tripId: number | string, perms?: Record<string, boolean>) => apiClient.post(`/trips/${tripId}/share-link`, perms || {}).then(r => r.data),
|
|
deleteLink: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/share-link`).then(r => r.data),
|
|
getSharedTrip: (token: string) => apiClient.get(`/shared/${token}`).then(r => r.data),
|
|
}
|
|
|
|
// Public transit routing (#1065) — Transitous/MOTIS proxied through the server.
|
|
export const transitApi = {
|
|
geocode: (q: string, opts?: { lang?: string; near?: string }) =>
|
|
apiClient.get('/transit/geocode', { params: { q, lang: opts?.lang, near: opts?.near } }).then(r => r.data),
|
|
plan: (params: { from: string; to: string; time?: string; arriveBy?: boolean; modes?: string; maxTransfers?: number }) =>
|
|
apiClient.get('/transit/plan', { params }).then(r => r.data),
|
|
}
|
|
|
|
// Trip invite links (#1143) — join a trip as an existing, logged-in user.
|
|
export const tripInviteApi = {
|
|
getLink: (tripId: number | string) => apiClient.get(`/trips/${tripId}/invite-link`).then(r => r.data),
|
|
createLink: (tripId: number | string, expires_in_days?: number | null) =>
|
|
apiClient.post(`/trips/${tripId}/invite-link`, { expires_in_days: expires_in_days ?? null }).then(r => r.data),
|
|
deleteLink: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/invite-link`).then(r => r.data),
|
|
preview: (token: string) => apiClient.get(`/trip-invites/${token}`).then(r => r.data),
|
|
accept: (token: string) => apiClient.post(`/trip-invites/${token}/accept`).then(r => r.data),
|
|
}
|
|
|
|
export const notificationsApi = {
|
|
getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data),
|
|
updatePreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data),
|
|
testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testSmtp')),
|
|
testWebhook: (url?: string) => apiClient.post('/notifications/test-webhook', { url }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testWebhook')),
|
|
testNtfy: (payload: { topic?: string; server?: string | null; token?: string | null }) => apiClient.post('/notifications/test-ntfy', payload).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testNtfy')),
|
|
// Generic channel test — this is how a PLUGIN channel's "Send test" button works.
|
|
testChannel: (channelId: string) =>
|
|
apiClient.post(`/notifications/test/${encodeURIComponent(channelId)}`)
|
|
.then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testChannel')),
|
|
}
|
|
|
|
export const inAppNotificationsApi = {
|
|
list: (params?: { limit?: number; offset?: number; unread_only?: boolean }): Promise<InAppListResult> =>
|
|
apiClient.get('/notifications/in-app', { params }).then(r => parseInDev(inAppListResultSchema, r.data, 'notifications.list')),
|
|
unreadCount: (): Promise<UnreadCountResult> =>
|
|
apiClient.get('/notifications/in-app/unread-count').then(r => parseInDev(unreadCountResultSchema, r.data, 'notifications.unreadCount')),
|
|
markRead: (id: number) =>
|
|
apiClient.put(`/notifications/in-app/${id}/read`).then(r => r.data),
|
|
markUnread: (id: number) =>
|
|
apiClient.put(`/notifications/in-app/${id}/unread`).then(r => r.data),
|
|
markAllRead: () =>
|
|
apiClient.put('/notifications/in-app/read-all').then(r => r.data),
|
|
delete: (id: number) =>
|
|
apiClient.delete(`/notifications/in-app/${id}`).then(r => r.data),
|
|
deleteAll: () =>
|
|
apiClient.delete('/notifications/in-app/all').then(r => r.data),
|
|
respond: (id: number, response: NotificationRespondRequest['response']) =>
|
|
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
|
|
}
|
|
|
|
export default apiClient
|