0104 was already implemented and already in the action catalogue; nothing routed to it, so the only way to see the device's list was as a side effect of writing a card. It has an endpoint now, and the panel a button. The two lists are compared where they meet: a card the charger holds and the account has forgotten still opens it, and a card only the account holds will not, and neither shows anywhere else. The comparison is drawn only when they disagree, and the device's reading is dropped on a refresh rather than measured against an account list from a later moment. The new test asks all four card routes without a token: a capability the plugin implements and the catalogue advertises is still unusable if nothing routes to it, and no other test here would notice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
420 lines
22 KiB
JavaScript
420 lines
22 KiB
JavaScript
// 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 <img src> or <a href> 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
|
|
// <a href>. 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" }),
|
|
};
|