Documents already carried a single optional file: upload, download, detach,
access-checked on every request and proxied through this server, so PocketBase's
files are never public URLs. Service records, workshop visits, refills and
catalog parts all want the same thing — a receipt, an invoice, a photo of the
box — so extend it to them.
Rather than copy the document handlers four more times, lift them into one
shared layer. Every attachable collection has a car relation and a file field,
which is what lets a single set of handlers authorize and serve all of them. An
upload finishes by delegating to the collection's own GET handler, so the
response carries the full record — derived fields and all — exactly as a re-read
would. The web side gets the same treatment: one picker component and one
upload-after-save helper behind all five forms. Net effect is five features for
about the cost of the one that was already there.
Alongside:
- parts gain a notes field
- Reminders moves behind Parts catalog in the car detail tabs
- the changed-part labels spell out in full ("Oil & Oil filter" rather than
"Oil & filter"), and the form and table now agree
The PocketBase schema must be migrated before the new attachments work:
scripts/setup-pocketbase.mjs adds the file fields and parts.notes. It is
additive and safe to re-run.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
221 lines
10 KiB
JavaScript
221 lines
10 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).
|
|
|
|
// 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 <img src> or <a href> 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
|
|
// <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
|
|
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" }),
|
|
};
|