import axios, { AxiosInstance } from 'axios' import type { z } from 'zod' import { weatherResultSchema, type WeatherResult, inAppListResultSchema, type InAppListResult, unreadCountResultSchema, type UnreadCountResult, channelTestResultSchema, mapsSearchResultSchema, mapsAutocompleteResultSchema, mapsPlaceDetailsResultSchema, mapsPlacePhotoResultSchema, mapsReverseResultSchema, mapsResolveUrlResultSchema, 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 AssignmentReorderRequest, type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest, type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest, 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 TodoCreateItemRequest, type TodoUpdateItemRequest, type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest, type PlaceBulkDeleteRequest, type DayNoteCreateRequest, type DayNoteUpdateRequest, type PackingImportRequest, type PackingBagMembersRequest, type PackingUpdateBagRequest, type PackingCategoryAssigneesRequest, 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, } from '@trek/shared' import { getSocketId } from './websocket' import { isReachable, 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(schema: S, data: unknown, label: string): z.infer { 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 } /** * 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(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 = { 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: 'Занадто багато спроб. Спробуйте пізніше.', } 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 { 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) { await probeNow() // Both the original request and the health probe failed while the device // has a network interface. This matches the proxy-auth-challenge pattern // (CF Access / Pangolin intercept all requests and CORS-block XHR). // Guard with sessionStorage to prevent reload loops (server genuinely // down would also land here, but only reloads once). if (!isReachable()) { 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 as { error?: string } | undefined if (data && typeof data === 'object') { data.error = translated } else { error.response.data = { error: translated } } error.message = translated } return Promise.reject(error) } ) 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) => apiClient.put('/auth/me/api-keys', data).then(r => r.data), updateSettings: (data: Record) => 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) => apiClient.post('/auth/avatar', formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data), deleteAvatar: () => apiClient.delete('/auth/avatar').then(r => r.data), getAppConfig: () => apiClient.get('/auth/app-config').then(r => r.data), updateAppSettings: (data: Record) => 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 }), 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) => 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), 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) => apiClient.post(`/trips/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).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), 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), 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) => 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), 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 apiClient.post(`/trips/${tripId}/places/import/gpx`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data) }, 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 apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data) }, 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), } 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) => 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), } 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), 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) => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`).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) => apiClient.post('/admin/users', data).then(r => r.data), updateUser: (id: number, data: Record) => 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) => apiClient.put('/admin/oidc', data).then(r => r.data), addons: () => apiClient.get('/admin/addons').then(r => r.data), updateAddon: (id: number | string, data: Record) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data), 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), getCollabFeatures: () => apiClient.get('/admin/collab-features').then(r => r.data), updateCollabFeatures: (features: Record) => 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), createInvite: (data: { max_uses: number; expires_in_days?: number }) => 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) => apiClient.put('/admin/permissions', { permissions }).then(r => r.data), rotateJwtSecret: () => apiClient.post('/admin/rotate-jwt-secret').then(r => r.data), sendTestNotification: (data: Record) => apiClient.post('/admin/dev/test-notification', data).then(r => r.data), getNotificationPreferences: () => apiClient.get('/admin/notification-preferences').then(r => r.data), updateNotificationPreferences: (prefs: Record>) => apiClient.put('/admin/notification-preferences', prefs).then(r => r.data), getDefaultUserSettings: () => apiClient.get('/admin/default-user-settings').then(r => r.data), updateDefaultUserSettings: (settings: Record) => apiClient.put('/admin/default-user-settings', settings).then(r => r.data), } export const addonsApi = { enabled: () => apiClient.get('/addons').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[]) => apiClient.post(`/trips/${tripId}/reservations/import/airtrail`, { 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) => 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) => apiClient.post(`/journeys/${id}/entries`, data).then(r => r.data), updateEntry: (entryId: number, data: Record) => 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?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) }, timeout: 0, onUploadProgress: opts?.onUploadProgress, signal: opts?.signal, }).then(r => r.data), uploadGalleryPhotos: (journeyId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) => apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, { headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) }, timeout: 0, onUploadProgress: opts?.onUploadProgress, signal: opts?.signal, }).then(r => r.data), addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) } 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) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).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) => 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) => apiClient.post(`/journeys/${id}/cover`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data), // 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')), 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 }, signal?: AbortSignal) => apiClient.get('/maps/pois', { params: { category, ...bbox }, 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 }) => 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), } 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) => apiClient.post(`/trips/${tripId}/files`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data), 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), 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[]): Promise => { const fd = new FormData() for (const f of files) fd.append('files', f) return apiClient.post(`/trips/${tripId}/reservations/import/booking`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data) }, importBookingConfirm: (tripId: number | string, items: BookingImportPreviewItem[]): Promise => apiClient.post(`/trips/${tripId}/reservations/import/booking/confirm`, { items }).then(r => r.data), } export const healthApi = { features: (): Promise<{ bookingImport: boolean }> => apiClient.get('/health/features').then(r => r.data), } export const weatherApi = { get: (lat: number, lng: number, date: string): Promise => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')), getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise => 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 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) => { 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) => apiClient.post(`/trips/${tripId}/collab/notes/${noteId}/files`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data), 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 => { 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 apiClient.post('/backup/upload-restore', form, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data) }, getAutoSettings: () => apiClient.get('/backup/auto-settings').then(r => r.data), setAutoSettings: (settings: Record) => 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) => 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), } export const notificationsApi = { getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data), updatePreferences: (prefs: Record>) => 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')), } export const inAppNotificationsApi = { list: (params?: { limit?: number; offset?: number; unread_only?: boolean }): Promise => apiClient.get('/notifications/in-app', { params }).then(r => parseInDev(inAppListResultSchema, r.data, 'notifications.list')), unreadCount: (): Promise => 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