// Single client for the Car Control API Server. The web app never talks to // PocketBase directly — only to these endpoints (proxied to the API Server in // dev via vite.config.js). // Default API base: the Vite env override, else the same-origin "/api" (proxied // to the API Server in dev). A user can override this at runtime via the login // screen's "Server settings" — stored in localStorage and used for every call. export const DEFAULT_API_BASE = import.meta.env.VITE_API_BASE || "/api"; const SERVER_KEY = "cc_server_url"; // Resolved fresh on each request so changing it takes effect without a reload. function apiBase() { return localStorage.getItem(SERVER_KEY) || DEFAULT_API_BASE; } export function getServerUrl() { return localStorage.getItem(SERVER_KEY) || ""; } // Persist a custom API base URL (trailing slashes trimmed). Empty/blank clears // the override, falling back to the default. export function setServerUrl(url) { const trimmed = (url || "").trim().replace(/\/+$/, ""); if (trimmed) localStorage.setItem(SERVER_KEY, trimmed); else localStorage.removeItem(SERVER_KEY); } export const TOKEN_KEY = "cc_token"; export const USER_KEY = "cc_user"; function authHeader() { const t = localStorage.getItem(TOKEN_KEY); return t ? { Authorization: "Bearer " + t } : {}; } async function handleResponse(res, path) { // An expired/invalid token on any non-login call ends the session. if (res.status === 401 && path !== "/auth/login") { localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(USER_KEY); if (location.pathname !== "/login") location.href = "/login"; throw new Error("Session expired — please log in again."); } if (res.status === 204) return null; const text = await res.text(); const data = text ? JSON.parse(text) : null; if (!res.ok) throw new Error(errorMessage(data, res.statusText)); return data; } // Digs a human-readable message out of the error shapes in play: this server's // {error}, and PocketBase's {message, data:{field:{message}}} — which the user // and organization endpoints relay verbatim, so a duplicate email arrives as a // per-field error rather than a flat string. function errorMessage(data, fallback) { if (!data || typeof data !== "object") return fallback; if (data.error) return data.error; const fieldErrors = Object.entries(data.data || {}) .map(([field, e]) => `${field}: ${e?.message || e}`) .filter(Boolean); if (fieldErrors.length) return fieldErrors.join("; "); return data.message || fallback; } async function request(path, options = {}) { const res = await fetch(apiBase() + path, { headers: { "Content-Type": "application/json", ...authHeader(), ...(options.headers || {}) }, ...options, }); return handleResponse(res, path); } // Like request(), but for multipart/form-data bodies (file uploads) — the // browser sets its own Content-Type (with boundary), so we must not. async function requestForm(path, options = {}) { const res = await fetch(apiBase() + path, { headers: { ...authHeader() }, ...options }); return handleResponse(res, path); } // Fetches a binary response (image, export file) as a Blob, since it needs the // Authorization header — a plain or can't attach one. async function requestBlob(path) { const res = await fetch(apiBase() + path, { headers: authHeader() }); if (res.status === 401) return handleResponse(res, path); if (!res.ok) throw new Error("Request failed: " + res.statusText); const filename = (res.headers.get("Content-Disposition") || "").match(/filename="([^"]+)"/)?.[1]; return { blob: await res.blob(), filename }; } // Attachments. Every record that can carry a file — documents, service records, // workshop visits, refills, catalog parts — exposes the same three endpoints // under its own path, so they are built from one place rather than spelled out // five times. // // The file is proxied by the API (PocketBase's files aren't public), so it needs // the auth header — hence a multipart POST and a Blob fetch rather than a plain // . Uploading addresses a record that must already exist; see // applyAttachment in lib/attachment.js for the order the forms use. const attachment = (path) => ({ upload: (id, file) => { const form = new FormData(); form.append("file", file); return requestForm(`${path}/${id}/file`, { method: "POST", body: form }); }, download: (id) => requestBlob(`${path}/${id}/file`), remove: (id) => request(`${path}/${id}/file`, { method: "DELETE" }), }); export const api = { // Auth login: (email, password) => request("/auth/login", { method: "POST", body: JSON.stringify({ email, password }) }), me: () => request("/auth/me"), // Cars listCars: () => request("/cars"), getCar: (id) => request(`/cars/${id}`), createCar: (body) => request("/cars", { method: "POST", body: JSON.stringify(body) }), updateCar: (id, body) => request(`/cars/${id}`, { method: "PATCH", body: JSON.stringify(body) }), deleteCar: (id) => request(`/cars/${id}`, { method: "DELETE" }), // Sharing (owner-only). A share grants another user read or write access. listCarShares: (carId) => request(`/cars/${carId}/shares`), addCarShare: (carId, email, permission) => request(`/cars/${carId}/shares`, { method: "POST", body: JSON.stringify({ email, permission }) }), removeCarShare: (carId, userId) => request(`/cars/${carId}/shares/${userId}`, { method: "DELETE" }), // Service records listCarServices: (carId) => request(`/cars/${carId}/service-records`), createService: (body) => request("/service-records", { method: "POST", body: JSON.stringify(body) }), updateService: (id, body) => request(`/service-records/${id}`, { method: "PATCH", body: JSON.stringify(body) }), deleteService: (id) => request(`/service-records/${id}`, { method: "DELETE" }), // Parts listCarParts: (carId) => request(`/cars/${carId}/parts`), createPart: (body) => request("/parts", { method: "POST", body: JSON.stringify(body) }), updatePart: (id, body) => request(`/parts/${id}`, { method: "PATCH", body: JSON.stringify(body) }), deletePart: (id) => request(`/parts/${id}`, { method: "DELETE" }), // Fuel. Consumption figures on each entry, and the rollup from fuel-stats, are // derived server-side from the full history — nothing here is stored. listCarFuel: (carId) => request(`/cars/${carId}/fuel-entries`), getCarFuelStats: (carId) => request(`/cars/${carId}/fuel-stats`), createFuel: (body) => request("/fuel-entries", { method: "POST", body: JSON.stringify(body) }), updateFuel: (id, body) => request(`/fuel-entries/${id}`, { method: "PATCH", body: JSON.stringify(body) }), deleteFuel: (id) => request(`/fuel-entries/${id}`, { method: "DELETE" }), // Maintenance log — workshop visits and repairs (not the service schedule). listCarMaintenance: (carId) => request(`/cars/${carId}/maintenance`), createMaintenance: (body) => request("/maintenance", { method: "POST", body: JSON.stringify(body) }), updateMaintenance: (id, body) => request(`/maintenance/${id}`, { method: "PATCH", body: JSON.stringify(body) }), deleteMaintenance: (id) => request(`/maintenance/${id}`, { method: "DELETE" }), // Documents — insurance, pollution certificates, … with renewal dates. listCarDocuments: (carId) => request(`/cars/${carId}/documents`), createDocument: (body) => request("/car-documents", { method: "POST", body: JSON.stringify(body) }), updateDocument: (id, body) => request(`/car-documents/${id}`, { method: "PATCH", body: JSON.stringify(body) }), deleteDocument: (id) => request(`/car-documents/${id}`, { method: "DELETE" }), // Attachments, one per record. Keyed by the same names CarDetail uses for its // tabs so a table row can reach for the right one generically. files: { documents: attachment("/car-documents"), services: attachment("/service-records"), maintenance: attachment("/maintenance"), fuel: attachment("/fuel-entries"), parts: attachment("/parts"), }, // Reminders. The list mixes stored reminders with read-only ones derived from // documents and service records (flagged `auto`; their ids start with "auto:"). listCarReminders: (carId) => request(`/cars/${carId}/reminders`), createReminder: (body) => request("/reminders", { method: "POST", body: JSON.stringify(body) }), updateReminder: (id, body) => request(`/reminders/${id}`, { method: "PATCH", body: JSON.stringify(body) }), deleteReminder: (id) => request(`/reminders/${id}`, { method: "DELETE" }), completeReminder: (id) => request(`/reminders/${id}/complete`, { method: "POST" }), // Admin — user management (admin or superadmin). Admins are scoped by the // server to their own organization; superadmins see everyone. listUsers: () => request("/users").then((r) => r.users), createUser: (body) => request("/users", { method: "POST", body: JSON.stringify(body) }).then((r) => r.user), updateUser: (id, body) => request(`/users/${id}`, { method: "PATCH", body: JSON.stringify(body) }).then((r) => r.user), // Password resets are a field on the user PATCH now, not a separate endpoint. setUserPassword: (id, password) => request(`/users/${id}`, { method: "PATCH", body: JSON.stringify({ password }) }).then((r) => r.user), deleteUser: (id) => request(`/users/${id}`, { method: "DELETE" }), // Settings — account/profile/appearance getMe: () => request("/me"), updateMe: (body) => request("/me", { method: "PATCH", body: JSON.stringify(body) }), changePassword: (oldPassword, newPassword) => request("/me/password", { method: "POST", body: JSON.stringify({ oldPassword, newPassword }) }), requestVerification: () => request("/me/verify/request", { method: "POST" }), uploadAvatar: (file) => { const form = new FormData(); form.append("avatar", file); return requestForm("/me/avatar", { method: "POST", body: form }); }, deleteAvatar: () => request("/me/avatar", { method: "DELETE" }), getAvatarBlob: () => requestBlob("/me/avatar"), // Settings — advanced / danger zone exportData: () => requestBlob("/me/export"), importData: (payload) => request("/me/import", { method: "POST", body: JSON.stringify(payload) }), requestAccountDeletion: (confirmEmail) => request("/me/delete", { method: "POST", body: JSON.stringify({ confirmEmail }) }), cancelAccountDeletion: () => request("/me/delete/cancel", { method: "POST" }), finalizeAccountDeletion: () => request("/me", { method: "DELETE" }), };