// 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). // // Which server the calls go to is servers.js's business: the base URL and the // bearer token are both read from whichever server is active, resolved fresh on // every request so switching takes effect without a reload. import { t } from "./i18n"; import { servers, serverById, baseFor, sessionFor, clearSession, setActive, isConnected, HOME_ID } from "./servers"; // Every request is pinned to the server that was active when it went out: its // base, its token, and — if it comes back 401 — its session and no other's. // Re-reading the active server on the way back would let one server's rejection // clear a different server's session, which is what happens when a page fires // several calls at once and the switch lands between them. function target() { const id = servers.activeId; const token = sessionFor(id)?.token; return { id, base: baseFor(serverById(id)), headers: token ? { Authorization: "Bearer " + token } : {}, }; } async function handleResponse(res, path, serverId) { // An expired/invalid token ends that server's session — but only that one. A // remote server timing out shouldn't tip you out of the app, so fall back to // the home server while it is still connected, and go to the login screen // only when nothing is left to fall back to. if (res.status === 401 && path !== "/auth/login") { clearSession(serverId); if (servers.activeId === serverId) { if (serverId !== HOME_ID && isConnected(HOME_ID)) setActive(HOME_ID); else if (location.pathname !== "/login") location.href = "/login"; } throw new Error(t("errors.sessionExpired")); } 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 to = target(); const res = await fetch(to.base + path, { headers: { "Content-Type": "application/json", ...to.headers, ...(options.headers || {}) }, ...options, }); return handleResponse(res, path, to.id); } // 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 to = target(); const res = await fetch(to.base + path, { headers: { ...to.headers }, ...options }); return handleResponse(res, path, to.id); } // 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 to = target(); const res = await fetch(to.base + path, { headers: to.headers }); if (res.status === 401) return handleResponse(res, path, to.id); 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. loginAt takes the base explicitly rather than using the active // server's: connecting to a newly added server must not make it active until // its credentials have actually worked. loginAt: async (base, email, password) => { const res = await fetch(base + "/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }), }); return handleResponse(res, "/auth/login", null); }, 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) }), // What this car's page shows — {hiddenTabs?, hiddenFields?}, as hidden sets. // Its own endpoint so an ordinary car edit — which sends every other field — // can never reveal something switched off. Needs write access, like editing // the car. Only the sets passed are written. updateCarView: (id, patch) => request(`/cars/${id}/view`, { method: "PUT", body: JSON.stringify(patch) }), 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" }), // Technical checks — roadworthiness inspections. nextCheckDate and the expiry // assessment are derived server-side from the certificate's valid-until date, // falling back to the car's interval. listCarTechnicalChecks: (carId) => request(`/cars/${carId}/technical-checks`), createTechnicalCheck: (body) => request("/technical-checks", { method: "POST", body: JSON.stringify(body) }), updateTechnicalCheck: (id, body) => request(`/technical-checks/${id}`, { method: "PATCH", body: JSON.stringify(body) }), deleteTechnicalCheck: (id) => request(`/technical-checks/${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" }), // Charging — the EV counterpart of fuel, on the same terms: the kWh/100km // figures and the charging-stats rollup are derived server-side from the full // history, so nothing here is stored. listCarCharging: (carId) => request(`/cars/${carId}/charging-sessions`), getCarChargingStats: (carId) => request(`/cars/${carId}/charging-stats`), createCharging: (body) => request("/charging-sessions", { method: "POST", body: JSON.stringify(body) }), updateCharging: (id, body) => request(`/charging-sessions/${id}`, { method: "PATCH", body: JSON.stringify(body) }), deleteCharging: (id) => request(`/charging-sessions/${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"), technical: attachment("/technical-checks"), maintenance: attachment("/maintenance"), fuel: attachment("/fuel-entries"), charging: attachment("/charging-sessions"), 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" }), // Organizations. Listing is manager-only (an admin sees just their own org), // but creating is open to any user without one — the creator becomes its // admin. Renaming and deleting are scoped to the caller's own org unless they // are a superadmin. listOrgs: () => request("/orgs").then((r) => r.organizations), createOrg: (name) => request("/orgs", { method: "POST", body: JSON.stringify({ name }) }).then((r) => r.organization), updateOrg: (id, name) => request(`/orgs/${id}`, { method: "PATCH", body: JSON.stringify({ name }) }).then((r) => r.organization), deleteOrg: (id) => request(`/orgs/${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"), // Integrations — per-user plugin settings under the superadmin → org admin → // user cascade. getToyota returns the resolved view (effective/own/locked per // field, with secrets and inherited usernames masked); saveToyota writes the // caller's editable layer (scope "user" by default, "org" for org admins); // testToyota runs a live login probe under the resolved credentials. getToyota: () => request("/integrations/toyota"), saveToyota: (body) => request("/integrations/toyota", { method: "PUT", body: JSON.stringify(body) }), testToyota: () => request("/integrations/toyota/health", { method: "POST" }), // Vehicle providers — manufacturer services a car can be created from, and the // data feed behind a car's provider tab. Every call runs server-side under the // caller's *own* connected account (the same cascade the Settings integrations // use), so a car shared from someone else only shows provider data when that // vehicle is on this user's account too. // // listVehicleProviders reports each provider with a `connected` flag and, when // it isn't, a `detail` sentence explaining what to do about it — the list is // never an error, so the UI can offer "connect in Settings" instead. listVehicleProviders: () => request("/vehicle-providers").then((r) => r.providers), listProviderVehicles: (provider) => request(`/vehicle-providers/${encodeURIComponent(provider)}/vehicles`), // include selects what to pull; omit it entirely to fetch everything available. importProviderVehicle: (provider, body) => request(`/vehicle-providers/${encodeURIComponent(provider)}/import`, { method: "POST", body: JSON.stringify(body), }), // One car's live provider snapshot: the vehicle record, headline readings, and // every section the plugin can fetch (each with its flattened fields and the // raw payload). linkCarProvider attaches an existing car to a vehicle — pass an // empty provider to detach; syncCarProvider re-applies provider data to the car. getCarProvider: (carId) => request(`/cars/${carId}/provider`), linkCarProvider: (carId, body) => request(`/cars/${carId}/provider`, { method: "POST", body: JSON.stringify(body) }), syncCarProvider: (carId, body = {}) => request(`/cars/${carId}/provider/sync`, { method: "POST", body: JSON.stringify(body) }), // Charger providers — the garage's import, aimed at the wall: a charger on a // connected service (Anker Solix, Greencell) becomes one of the caller's own // home chargers. listChargerProviders reports each with a `connected` flag and, // when it isn't, a `detail` sentence saying what to do about it. listChargerProviders: () => request("/charger-providers").then((r) => r.providers), listProviderChargers: (provider) => request(`/charger-providers/${encodeURIComponent(provider)}/chargers`), importProviderCharger: (provider, body) => request(`/charger-providers/${encodeURIComponent(provider)}/import`, { method: "POST", body: JSON.stringify(body), }), // The caller's own chargers. Only the name is editable — everything else // describes the hardware and comes from the service it was imported from. listHomeChargers: () => request("/home-chargers").then((r) => r.chargers), renameHomeCharger: (id, name) => request(`/home-chargers/${encodeURIComponent(id)}`, { method: "PATCH", body: JSON.stringify({ name }), }), deleteHomeCharger: (id) => request(`/home-chargers/${encodeURIComponent(id)}`, { method: "DELETE" }), // Anker Solix (V1 Smart EV Charger) — same cascade as Toyota. getAnkerSolix // returns the resolved view (effective/own/locked per field, secrets and // inherited emails masked); saveAnkerSolix writes the caller's editable layer; // testAnkerSolix runs a live login probe under the resolved credentials. getAnkerSolix: () => request("/integrations/anker-solix"), saveAnkerSolix: (body) => request("/integrations/anker-solix", { method: "PUT", body: JSON.stringify(body) }), testAnkerSolix: () => request("/integrations/anker-solix/health", { method: "POST" }), // The chargers on the linked Anker account, fetched server-side under the // resolved credentials: {chargers, count, boundCount?, detail?}. Answers 200 // with an empty list and a reason when a gate is off, so the caller can show // the reason rather than an error. listAnkerChargers: () => request("/integrations/anker-solix/chargers"), // Every view the account holds about one charger — the station record, the // totals, the history, the sessions, the OCPP backend, the cards, the sharing, // the firmware and the rest, plus its site's views when it has a site — asked // for one charger at a time, because none of those endpoints lists chargers. getAnkerChargerDetails: (sn) => request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/details`), // The RFID cards on one charger — the only calls in this client that change // anything on the Anker account. Anker documents neither endpoint, so the // server infers the request and then reads the list back: both of these answer // with {present, cards}, and it is the list that says what happened, not the // status code. saveAnkerRfidCard: (sn, cardNumber, cardName) => request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/rfid-cards`, { method: "POST", body: JSON.stringify({ cardNumber, cardName }), }), // Opens the charger's own card reader and waits for a tap — the request is in // flight for the whole twenty-second window, and answers whether or not a card // arrived. scanAnkerRfidCard: (sn) => request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/rfid-cards/scan`, { method: "POST", }), deleteAnkerRfidCard: (sn, cardNumber) => request( `/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/rfid-cards/${encodeURIComponent(cardNumber)}`, { method: "DELETE" } ), // The list the charger itself holds, asked of the device rather than of the // account. Both are written by every add and remove, and they can still come // apart; this is the only call that says so. Answers with {cards} — bare // numbers, because the device has no field for a card's name. getAnkerChargerCards: (sn) => request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/rfid-cards/charger`), // Anker Solix control (per charger), over whichever transport the user's // control mode selects. getAnkerControl returns the control mode, connection // status, and a live status snapshot — an OCPP session snapshot in own/proxy // mode, the charger's own snapshot in modbus and mqtt mode. // // The modes are provisioned differently. OCPP needs a token the operator // installs into the charger (ankerControlToken / ankerControlRevoke); Modbus // needs the charger's address on the local network (ankerControlAddress / // ankerControlForgetAddress); the Anker cloud mode needs neither, because it // signs in as the account and reaches the charger through Anker's own broker — // which is why it is the mode for a charger the server cannot route to. A // charger may hold both bindings; setting one leaves the other alone. getAnkerControl: (sn) => request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/control`), ankerControlToken: (sn) => request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/control/token`, { method: "POST" }), ankerControlRevoke: (sn) => request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/control/token`, { method: "DELETE" }), ankerControlAddress: (sn, host, port) => request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/control/address`, { method: "PUT", body: JSON.stringify({ host, port }), }), ankerControlForgetAddress: (sn) => request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/control/address`, { method: "DELETE" }), // One control command. Over OCPP: start, stop, limit, clear-limit, // availability, reset, unlock, trigger, config. Over Modbus TCP: start, stop, // limit, boost, phase, timeout, status. Over the Anker cloud: start, stop, // limit, boost, skip-delay, status. A command a transport cannot send is // refused by name, saying which transport can. ankerControlAction: (sn, action, body = {}) => request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/${action}`, { method: "POST", body: JSON.stringify(body), }), // Greencell (HabuDen EV charger) — same cascade again, but what resolves is an // MQTT broker rather than a cloud account: the charger publishes to a broker // the owner runs, and the server joins it as a client. getGreencell returns the // resolved view (secrets and inherited host/username masked); saveGreencell // writes the caller's editable layer; testGreencell connects to the broker and // broadcasts for devices. getGreencell: () => request("/integrations/greencell"), saveGreencell: (body) => request("/integrations/greencell", { method: "PUT", body: JSON.stringify(body) }), testGreencell: () => request("/integrations/greencell/health", { method: "POST" }), // 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" }), };