The charger's own cloud schedule is one window inside one box: charge between these hours, every day, and that is the whole vocabulary. A third tab on Charging holds a list instead — each line an action, a time, the days it repeats on and the chargers it acts on — and one list covers the whole account rather than each charger hiding its own. The clock is the server's. A schedule that only fires while a tab is open is a reminder, so a ticker sweeps every enabled task and fires whichever minute has come. It sends by handing a synthesised request to the same control endpoint the page's buttons use, so a scheduled command goes through the same cascade, ownership gate, rate limit and audit trail — what the owner cannot press by hand, the scheduler cannot send for them. A task names its chargers, or names none, which means all of them and keeps meaning that for a charger imported next year. Times are stored as a wall clock plus the zone they were written in, so 23:00 stays 23:00 wherever the server sits. One action per task: a charging window is the two tasks that open and close it, which is how it is read back, edited and switched off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
669 lines
27 KiB
JavaScript
669 lines
27 KiB
JavaScript
// Idempotent PocketBase schema setup for the Car Control project.
|
|
//
|
|
// Creates the collections behind the app: cars, service_records and parts (which
|
|
// match the original "Car Service.xlsx"), the sharing/tenancy tables, and the
|
|
// fuel, maintenance, document and reminder logs layered on top. Access rules are
|
|
// left admin-only (null) on purpose: every client goes through the API Server,
|
|
// which authenticates as a superuser, so the database is never exposed directly
|
|
// — including document attachments, which are proxied by the API rather than
|
|
// served as public file URLs.
|
|
//
|
|
// Usage (PowerShell):
|
|
// $env:PB_URL="http://10.2.1.10:8027"
|
|
// $env:PB_ADMIN_EMAIL="you@example.com"
|
|
// $env:PB_ADMIN_PASSWORD="secret"
|
|
// node scripts/setup-pocketbase.mjs
|
|
//
|
|
// Re-running is safe: existing collections are skipped.
|
|
|
|
const PB_URL = (process.env.PB_URL || "http://10.2.1.10:8027").replace(/\/+$/, "");
|
|
const EMAIL = process.env.PB_ADMIN_EMAIL;
|
|
const PASSWORD = process.env.PB_ADMIN_PASSWORD;
|
|
|
|
if (!EMAIL || !PASSWORD) {
|
|
console.error("Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD environment variables.");
|
|
process.exit(1);
|
|
}
|
|
|
|
async function authenticate() {
|
|
const endpoints = [
|
|
"/api/collections/_superusers/auth-with-password",
|
|
"/api/admins/auth-with-password",
|
|
];
|
|
for (const ep of endpoints) {
|
|
const res = await fetch(PB_URL + ep, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ identity: EMAIL, password: PASSWORD }),
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
return data.token;
|
|
}
|
|
}
|
|
throw new Error("Authentication failed. Check PB_ADMIN_EMAIL / PB_ADMIN_PASSWORD.");
|
|
}
|
|
|
|
async function listCollections(token) {
|
|
const res = await fetch(PB_URL + "/api/collections?perPage=200", {
|
|
headers: { Authorization: token },
|
|
});
|
|
if (!res.ok) throw new Error(`list collections failed: ${res.status} ${await res.text()}`);
|
|
const data = await res.json();
|
|
return Array.isArray(data) ? data : data.items || [];
|
|
}
|
|
|
|
// Detects whether this PocketBase version serializes fields under "fields"
|
|
// (v0.23+) or the legacy "schema" key.
|
|
function detectFormat(collections) {
|
|
for (const c of collections) {
|
|
if (Array.isArray(c.fields)) return "fields";
|
|
if (Array.isArray(c.schema)) return "schema";
|
|
}
|
|
return "fields"; // default to modern format
|
|
}
|
|
|
|
// Field builders normalized to {name,type,required,relTo}. They are rendered
|
|
// into the right wire shape per detected format.
|
|
const F = {
|
|
text: (name, required = false) => ({ name, type: "text", required }),
|
|
number: (name) => ({ name, type: "number", required: false }),
|
|
bool: (name) => ({ name, type: "bool", required: false }),
|
|
date: (name, required = false) => ({ name, type: "date", required }),
|
|
relation: (name, relTo, required = false, cascadeDelete = true) => ({ name, type: "relation", required, relTo, cascadeDelete }),
|
|
select: (name, values, required = false) => ({ name, type: "select", required, values }),
|
|
autodate: (name, onCreate = false, onUpdate = false) => ({ name, type: "autodate", required: false, onCreate, onUpdate }),
|
|
// Single-file attachment. maxSize is in bytes; mimeTypes [] means "any".
|
|
file: (name, maxSize, mimeTypes = []) => ({ name, type: "file", required: false, maxSize, mimeTypes }),
|
|
// Free-form JSON blob. maxSize is in bytes.
|
|
json: (name, maxSize = 100000) => ({ name, type: "json", required: false, maxSize }),
|
|
};
|
|
|
|
function renderField(def, format, idByName) {
|
|
if (format === "schema") {
|
|
// Legacy: options nested under "options".
|
|
const options = {};
|
|
if (def.type === "relation") {
|
|
options.collectionId = idByName[def.relTo];
|
|
options.cascadeDelete = def.cascadeDelete !== false;
|
|
options.maxSelect = 1;
|
|
options.minSelect = 0;
|
|
}
|
|
if (def.type === "select") {
|
|
options.values = def.values;
|
|
options.maxSelect = 1;
|
|
}
|
|
if (def.type === "file") {
|
|
options.maxSelect = 1;
|
|
options.maxSize = def.maxSize;
|
|
options.mimeTypes = def.mimeTypes || [];
|
|
}
|
|
if (def.type === "json") {
|
|
options.maxSize = def.maxSize;
|
|
}
|
|
return { name: def.name, type: def.type, required: def.required, options };
|
|
}
|
|
// Modern: options flattened onto the field.
|
|
const field = { name: def.name, type: def.type, required: def.required };
|
|
if (def.type === "relation") {
|
|
field.collectionId = idByName[def.relTo];
|
|
field.cascadeDelete = def.cascadeDelete !== false;
|
|
field.maxSelect = 1;
|
|
field.minSelect = 0;
|
|
}
|
|
if (def.type === "select") {
|
|
field.values = def.values;
|
|
field.maxSelect = 1;
|
|
}
|
|
if (def.type === "autodate") {
|
|
field.onCreate = def.onCreate;
|
|
field.onUpdate = def.onUpdate;
|
|
}
|
|
if (def.type === "file") {
|
|
field.maxSelect = 1;
|
|
field.maxSize = def.maxSize;
|
|
field.mimeTypes = def.mimeTypes || [];
|
|
}
|
|
if (def.type === "json") {
|
|
field.maxSize = def.maxSize;
|
|
}
|
|
return field;
|
|
}
|
|
|
|
async function createCollection(token, name, defs, format, idByName) {
|
|
const rendered = defs.map((d) => renderField(d, format, idByName));
|
|
const body = {
|
|
name,
|
|
type: "base",
|
|
[format]: rendered, // "fields" or "schema"
|
|
// Rules left null => superuser-only access (API Server is the only client).
|
|
listRule: null,
|
|
viewRule: null,
|
|
createRule: null,
|
|
updateRule: null,
|
|
deleteRule: null,
|
|
};
|
|
if (INDEXES[name]) body.indexes = INDEXES[name];
|
|
const res = await fetch(PB_URL + "/api/collections", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Authorization: token },
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (!res.ok) throw new Error(`create ${name} failed: ${res.status} ${await res.text()}`);
|
|
const created = await res.json();
|
|
idByName[name] = created.id;
|
|
return created;
|
|
}
|
|
|
|
async function getCollection(token, idOrName) {
|
|
const res = await fetch(PB_URL + "/api/collections/" + idOrName, {
|
|
headers: { Authorization: token },
|
|
});
|
|
if (!res.ok) throw new Error(`get ${idOrName} failed: ${res.status} ${await res.text()}`);
|
|
return res.json();
|
|
}
|
|
|
|
// reconcileFields brings an existing collection's schema in line with the desired
|
|
// definition: it appends any missing fields AND updates relation options
|
|
// (currently cascadeDelete) and select options (the "values" list) on existing
|
|
// fields. Existing field ids/data are kept. Safe to re-run as the schema
|
|
// evolves (e.g. adding cars.current_km, enabling cascade delete, or adding a
|
|
// new select choice like a date-format option).
|
|
async function reconcileFields(token, name, defs, format, idByName) {
|
|
const col = await getCollection(token, name);
|
|
const current = col[format] || [];
|
|
const byName = new Map(current.map((f) => [f.name, f]));
|
|
|
|
const changes = [];
|
|
|
|
// Update relation cascadeDelete and select values on existing fields to match desired.
|
|
const merged = current.map((f) => {
|
|
const def = defs.find((d) => d.name === f.name);
|
|
if (def && def.type === "relation") {
|
|
const wantCascade = def.cascadeDelete !== false;
|
|
if (f.cascadeDelete !== wantCascade) {
|
|
changes.push(`${f.name}.cascadeDelete=${wantCascade}`);
|
|
return { ...f, cascadeDelete: wantCascade };
|
|
}
|
|
}
|
|
if (def && def.type === "select") {
|
|
const same =
|
|
Array.isArray(f.values) &&
|
|
f.values.length === def.values.length &&
|
|
def.values.every((v) => f.values.includes(v));
|
|
if (!same) {
|
|
changes.push(`${f.name}.values=[${def.values.join(",")}]`);
|
|
return { ...f, values: def.values };
|
|
}
|
|
}
|
|
return f;
|
|
});
|
|
|
|
// Append missing fields.
|
|
const missing = defs.filter((d) => !byName.has(d.name));
|
|
for (const d of missing) {
|
|
merged.push(renderField(d, format, idByName));
|
|
changes.push(`+${d.name}`);
|
|
}
|
|
|
|
if (changes.length === 0) {
|
|
console.log(`• ${name} — up to date`);
|
|
return;
|
|
}
|
|
const res = await fetch(PB_URL + "/api/collections/" + col.id, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json", Authorization: token },
|
|
body: JSON.stringify({ [format]: merged }),
|
|
});
|
|
if (!res.ok) throw new Error(`update ${name} failed: ${res.status} ${await res.text()}`);
|
|
console.log(`✓ ${name} — ${changes.join(", ")}`);
|
|
}
|
|
|
|
// The single optional attachment a record can carry — a scan, a receipt, a
|
|
// workshop invoice, a photo of a part. The 10MB cap matches maxAttachmentUpload
|
|
// in the API server, and the file is reached only via the API's own file
|
|
// endpoint, never as a public URL.
|
|
const attachment = () =>
|
|
F.file("file", 10485760, [
|
|
"application/pdf",
|
|
"image/jpeg",
|
|
"image/png",
|
|
"image/webp",
|
|
"image/heic",
|
|
]);
|
|
|
|
// Desired schema. Edit here to evolve collections; re-run the script to apply.
|
|
const DESIRED = {
|
|
cars: [
|
|
F.text("name", true),
|
|
F.text("make"),
|
|
F.text("model"),
|
|
F.number("year"),
|
|
F.text("registration"),
|
|
F.text("registration_country"),
|
|
F.text("vin"),
|
|
F.number("service_interval_days"),
|
|
F.number("service_interval_km"),
|
|
// Roadworthiness inspection cycle. Only prefills a check's next-due date —
|
|
// the legal interval changes as the car ages, so each check can override it.
|
|
F.number("technical_check_interval_days"),
|
|
F.text("oil_spec"),
|
|
F.text("transmission_oil_spec"),
|
|
F.text("differential_oil_spec"),
|
|
F.text("brake_fluid_spec"),
|
|
F.text("coolant_spec"),
|
|
F.number("current_km"),
|
|
// Bi-fuel LPG conversions are their own choice rather than a flag: the car
|
|
// runs on either tank, so "petrol + LPG" is what an owner picks it out as.
|
|
F.select("fuel_type", [
|
|
"petrol",
|
|
"petrol_lpg",
|
|
"diesel",
|
|
"diesel_lpg",
|
|
"hybrid",
|
|
"electric",
|
|
"hydrogen",
|
|
]),
|
|
F.text("build_date"), // ISO YYYY-MM-DD (date-only; VIN 10th digit ≈ model year)
|
|
F.text("first_registration_date"), // ISO YYYY-MM-DD
|
|
// Link to the manufacturer service this car came from (see
|
|
// internal/api/vehicleproviders.go): the plugin name, plus that plugin's own
|
|
// id for the vehicle (the VIN, for Toyota). Set when a car is imported from
|
|
// or linked to a connected account; blank for a hand-entered car.
|
|
F.text("provider"),
|
|
F.text("provider_vehicle_id"),
|
|
// What this car's page shows: the tabs switched off (["fuel"] on an EV) and
|
|
// the Information rows switched off (["differentialOil"]). Properties of the
|
|
// car, so everyone it is shared with sees the same page. The hidden sets,
|
|
// not the visible ones, so anything added in a later release is on by
|
|
// default. Keys are validated in internal/api/cars.go.
|
|
F.json("hidden_tabs", 2000),
|
|
F.json("hidden_fields", 2000),
|
|
// The order the tabs are laid out in, as tab keys, and the order the
|
|
// Information rows are laid out in, as field keys — the hidden ones
|
|
// included in both, so one switched back on returns to where it was. Empty
|
|
// means the page's own default order. metric_order is the same for the
|
|
// headline readings on the connected service's tab.
|
|
F.json("tab_order", 2000),
|
|
F.json("field_order", 2000),
|
|
F.json("metric_order", 2000),
|
|
// Owner of this car. Non-cascading on purpose: deleting a user must not
|
|
// wipe their cars (account deletion in me.go intentionally leaves cars).
|
|
// required:false at the DB level — the API always sets owner on create and
|
|
// existing rows are backfilled (scripts/backfill-car-owners.mjs).
|
|
F.relation("owner", "users", false, false),
|
|
],
|
|
service_records: [
|
|
F.relation("car", "cars", true),
|
|
F.date("date", true),
|
|
F.number("km"),
|
|
F.bool("changed_oil"),
|
|
F.bool("changed_engine_air_filter"),
|
|
F.bool("changed_cabin_air_filter"),
|
|
F.text("notes"),
|
|
attachment(), // the workshop receipt / stamped service-book page
|
|
],
|
|
// Mandatory roadworthiness inspections (przegląd techniczny / MOT / TÜV).
|
|
// Like service_records but time-only: a check falls due on a date whatever the
|
|
// odometer reads. valid_until is the expiry printed on the certificate; when
|
|
// blank the API derives it from the car's interval.
|
|
technical_checks: [
|
|
F.relation("car", "cars", true),
|
|
F.date("date", true),
|
|
F.select("result", ["passed", "failed"]),
|
|
F.number("cost"),
|
|
F.text("station"),
|
|
F.date("valid_until"),
|
|
F.text("notes"),
|
|
attachment(), // the certificate
|
|
],
|
|
parts: [
|
|
F.relation("car", "cars", true),
|
|
F.text("name", true),
|
|
F.text("part_number"),
|
|
F.text("category"),
|
|
F.text("notes"),
|
|
attachment(), // a photo of the box, or the part's spec sheet
|
|
],
|
|
// Fuel refills. Consumption is NOT stored — the API derives it from the whole
|
|
// history on read (models.ComputeFuelDerived), so correcting an old fill fixes
|
|
// every figure it affects with no rows to migrate.
|
|
fuel_entries: [
|
|
F.relation("car", "cars", true),
|
|
F.date("date", true),
|
|
F.number("km"), // odometer at the pump
|
|
F.number("liters"),
|
|
F.number("cost"),
|
|
// Filled to the brim — the reference point efficiency is measured between.
|
|
F.bool("full_tank"),
|
|
// A refill happened before this one without being logged, so any window
|
|
// containing it is left uncomputed rather than reported as implausibly good.
|
|
F.bool("missed_fill"),
|
|
F.text("station"),
|
|
F.text("notes"),
|
|
attachment(), // the pump receipt
|
|
],
|
|
// Charging sessions for an electric car. The EV counterpart of fuel_entries
|
|
// and deliberately the same shape: kWh where litres would be, a charge to the
|
|
// usual full point as the reference the windows are measured between, and
|
|
// consumption derived on read (models.ComputeChargingDerived).
|
|
charging_sessions: [
|
|
F.relation("car", "cars", true),
|
|
F.date("date", true),
|
|
F.number("km"), // odometer when plugging in
|
|
F.number("kwh"),
|
|
F.number("cost"),
|
|
// Charged to the car's usual full point — the reference point.
|
|
F.bool("full_charge"),
|
|
// The car was charged before this without being logged, so any window
|
|
// containing it is left uncomputed rather than reported as implausible.
|
|
F.bool("missed_session"),
|
|
F.text("location"), // "Home", "Ionity Koge"
|
|
F.text("notes"),
|
|
attachment(), // the charge point's receipt
|
|
],
|
|
// Workshop visits and repairs. Deliberately separate from service_records:
|
|
// that collection is the routine interval schedule (and drives next-service
|
|
// due), this one is unplanned/one-off garage work with a labour bill.
|
|
maintenance_entries: [
|
|
F.relation("car", "cars", true),
|
|
F.date("date", true),
|
|
F.number("km"),
|
|
F.select("type", ["repair", "inspection", "bodywork", "tyres", "diagnostics", "recall", "warranty", "other"]),
|
|
F.select("status", ["scheduled", "in_progress", "completed"]),
|
|
F.text("workshop"),
|
|
F.text("location"),
|
|
F.text("description"),
|
|
F.text("parts_used"),
|
|
F.number("labor_cost"),
|
|
F.number("parts_cost"),
|
|
F.text("invoice_number"),
|
|
F.date("warranty_until"),
|
|
F.text("notes"),
|
|
attachment(), // the workshop's invoice
|
|
],
|
|
// Insurance, pollution certificates, registration papers … The expiry date is
|
|
// the point of the record: it drives the renewal status badges and the
|
|
// auto-derived reminders. Blank expiry = never expires.
|
|
car_documents: [
|
|
F.relation("car", "cars", true),
|
|
F.select("type", ["insurance", "pollution", "registration", "inspection", "roadTax", "warranty", "other"]),
|
|
F.text("title", true),
|
|
F.text("provider"),
|
|
F.text("reference"),
|
|
F.date("issue_date"),
|
|
F.date("expiry_date"),
|
|
F.number("cost"),
|
|
F.text("notes"),
|
|
attachment(), // the scan/PDF of the paperwork itself
|
|
],
|
|
// User-set reminders. The API additionally synthesises read-only ones from
|
|
// document expiry dates and the next service due — those are derived on read
|
|
// and have no rows here.
|
|
reminders: [
|
|
F.relation("car", "cars", true),
|
|
F.text("title", true),
|
|
F.select("type", ["maintenance", "document", "service", "inspection", "other"]),
|
|
F.date("due_date"),
|
|
F.number("due_km"),
|
|
// Non-zero => recurring: completing rolls the trigger forward by this much.
|
|
F.number("repeat_days"),
|
|
F.number("repeat_km"),
|
|
F.bool("done"),
|
|
F.date("done_at"),
|
|
F.text("notes"),
|
|
],
|
|
// Per-car sharing grants. One row = "this user may access this car" at the
|
|
// given permission. Cascades on both relations so grants disappear when
|
|
// either the car or the user is deleted. (Owner access is NOT stored here —
|
|
// it's implied by cars.owner.)
|
|
car_shares: [
|
|
F.relation("car", "cars", true),
|
|
F.relation("user", "users", true),
|
|
F.select("permission", ["read", "write"], true),
|
|
F.autodate("created", true, false),
|
|
],
|
|
// Append-only audit trail for OCPP charger control (start/stop/limit/reset/
|
|
// unlock/…, token generate/revoke, and charger connects). Actor/org are stored
|
|
// as plain text ids (not relations) so the trail survives user or org deletion.
|
|
// Written best-effort by the API Server (internal/api/integrations_ankersolix_control.go);
|
|
// if this collection is absent, control still works and only the structured log
|
|
// line remains.
|
|
control_audit: [
|
|
F.text("user_id"),
|
|
F.text("org_id"),
|
|
F.text("serial"),
|
|
F.text("action", true),
|
|
F.text("result"),
|
|
F.json("params", 10000),
|
|
F.autodate("created", true, false),
|
|
],
|
|
// The chargers a user owns — their own wallbox, not the public network. A
|
|
// charger belongs to a person rather than to a car: it charges whichever car
|
|
// is plugged into it, and it outlives any of them. Created by importing from a
|
|
// connected charger service (Anker Solix, Greencell); see
|
|
// internal/api/chargerproviders.go.
|
|
home_chargers: [
|
|
F.text("name", true),
|
|
F.text("serial"),
|
|
F.text("vendor"), // "Anker Solix", "Greencell" — who makes it
|
|
F.text("model"), // "A5191"
|
|
F.text("site_name"),
|
|
F.number("power_kw"),
|
|
F.text("connector"),
|
|
// Where this charger came from: the provider id plus that provider's own id
|
|
// for it (the serial, for both providers we speak to). Blank when added by hand.
|
|
F.text("provider"),
|
|
F.text("provider_charger_id"),
|
|
// Owner. Non-cascading, like a car's: deleting a user must not wipe their records.
|
|
F.relation("owner", "users", false, false),
|
|
// A charger carries no date of its own, so the import order is the only order
|
|
// there is to list them in. PocketBase adds no created field to a collection
|
|
// defined through the API, so it is declared here like the audit trail's.
|
|
F.autodate("created", true, false),
|
|
],
|
|
// The home-charger scheduler: a user's own list of charging tasks, one list
|
|
// covering every charger they own. The charger's own cloud schedule holds one
|
|
// window per box; this holds as many tasks as they like, each naming its own
|
|
// chargers, days and action. Run by the ticker in the API Server
|
|
// (internal/api/chargingtasks_run.go).
|
|
charging_tasks: [
|
|
F.text("name", true),
|
|
// The home_chargers rows this task acts on. A list of ids rather than a
|
|
// relation because empty has to mean "every charger I own" — a standing wish
|
|
// that keeps covering chargers imported later.
|
|
F.json("chargers", 2000),
|
|
F.select("action", ["start", "stop", "limit", "boost"], true),
|
|
F.number("amps"), // the ceiling, for the "limit" action
|
|
// 24-hour "HH:MM", read in the IANA zone the task was written in.
|
|
F.text("time", true),
|
|
F.text("zone"),
|
|
F.json("days", 200), // 0=Sunday … 6=Saturday; empty means every day
|
|
F.bool("enabled"),
|
|
F.text("last_run"), // RFC3339, UTC — also the guard against a double firing
|
|
F.text("last_result"),
|
|
// Owner. Non-cascading, like a charger's.
|
|
F.relation("owner", "users", false, false),
|
|
F.autodate("created", true, false),
|
|
],
|
|
// Server-wide settings as a single record, keyed "global". Today it holds
|
|
// pluginSettings: the top (L1) layer of the integration cascade — every
|
|
// plugin's enable state, its global config, and the registration of any
|
|
// external HTTP plugin. The org and user layers below keep their own plugin
|
|
// config in a field of the same name. See internal/plugins/store.go.
|
|
app_settings: [
|
|
F.text("key", true),
|
|
F.json("pluginSettings", 200000),
|
|
],
|
|
// Tenants that users belong to. A superadmin spans all of them; an admin
|
|
// manages only their own.
|
|
organizations: [
|
|
F.text("name", true),
|
|
F.autodate("created", true, false),
|
|
// Per-organization plugin/integration config — the middle (org admin) layer
|
|
// of the integration cascade (API Server → org admin → user). Shape:
|
|
// { "<plugin>": { "config": {…}, "disabled": bool } }
|
|
// See internal/api/integrations.go. Only meaningful for plugins that expose
|
|
// a per-user cascade (today: toyota).
|
|
F.json("pluginSettings"),
|
|
],
|
|
// Custom fields layered onto the built-in "users" auth collection (which
|
|
// already ships with email/name/avatar). Settings-panel additions:
|
|
users: [
|
|
F.text("bio"),
|
|
F.select("theme", ["light", "dark", "system"]),
|
|
F.text("locale"),
|
|
F.select("date_format", ["YMD", "DMY_NUM", "DMY", "MDY"]),
|
|
// "auto" is the region's own convention. Kept in step with
|
|
// validTimeFormats in internal/api/me.go.
|
|
F.select("time_format", ["auto", "24", "12"]),
|
|
// European currencies plus the non-European ones the panel already offered.
|
|
// Kept in step with validCurrencies in internal/api/me.go and CURRENCY_CODES
|
|
// in the web app's Settings.vue.
|
|
F.select("currency", [
|
|
"EUR", "GBP", "CHF", "PLN", "CZK", "HUF", "RON", "BGN", "DKK", "SEK", "NOK",
|
|
"ISK", "ALL", "AMD", "AZN", "BAM", "BYN", "GEL", "MDL", "MKD", "RSD", "RUB",
|
|
"TRY", "UAH", "USD", "CAD", "AUD", "JPY",
|
|
]),
|
|
F.select("font_size", ["small", "medium", "large"]),
|
|
// Holds every arrangement on this user's pages still — the garage, a car's
|
|
// tabs and Information rows, the provider's readings — so reading a page
|
|
// cannot nudge its layout. Per user, like car_order.
|
|
F.bool("drag_locked"),
|
|
F.date("deletion_requested_at"),
|
|
// Access role. Empty value is treated as "user" by the API.
|
|
F.select("role", ["user", "admin", "superadmin"]),
|
|
// Organization membership. Non-cascading on purpose: deleting an org must
|
|
// not delete its people. (The API refuses to delete an org that still has
|
|
// members, so this should not arise in practice.)
|
|
F.relation("organization", "organizations", false, false),
|
|
// Per-user plugin/integration config — the bottom (user) layer of the
|
|
// integration cascade. Shape:
|
|
// { "<plugin>": { "config": {…}, "enabled": bool } }
|
|
// The `enabled` flag is the personal opt-in; see internal/api/integrations.go.
|
|
F.json("pluginSettings"),
|
|
// The garage order: car ids as this user dragged them, e.g. ["c2","c1"].
|
|
// Per user rather than per car, so it also covers cars shared with them and
|
|
// never reorders somebody else's garage. See internal/api/cars.go.
|
|
F.json("car_order", 20000),
|
|
// The charging page's tab and card arrangements, and which tab each tabbed
|
|
// page opens on ({"charging":"home",…}). Small lists and a three-entry map,
|
|
// so none of them need car_order's room.
|
|
F.json("charger_tab_order", 2000),
|
|
F.json("charger_card_order", 2000),
|
|
F.json("default_tabs", 2000),
|
|
],
|
|
};
|
|
|
|
// Extra SQL indexes, applied at collection-create time. Organization names are
|
|
// unique so the API can rely on PocketBase rejecting a duplicate.
|
|
const INDEXES = {
|
|
// One settings record per key, so the global singleton cannot be duplicated.
|
|
app_settings: ["CREATE UNIQUE INDEX `idx_app_settings_key` ON `app_settings` (`key`)"],
|
|
organizations: ["CREATE UNIQUE INDEX `idx_organizations_name` ON `organizations` (`name`)"],
|
|
// Every read of these is "…for this car", and the fuel history is walked in
|
|
// odometer order to build its efficiency windows.
|
|
fuel_entries: ["CREATE INDEX `idx_fuel_entries_car_km` ON `fuel_entries` (`car`, `km`)"],
|
|
charging_sessions: ["CREATE INDEX `idx_charging_sessions_car_km` ON `charging_sessions` (`car`, `km`)"],
|
|
maintenance_entries: ["CREATE INDEX `idx_maintenance_entries_car_date` ON `maintenance_entries` (`car`, `date`)"],
|
|
car_documents: ["CREATE INDEX `idx_car_documents_car_expiry` ON `car_documents` (`car`, `expiry_date`)"],
|
|
reminders: ["CREATE INDEX `idx_reminders_car_due` ON `reminders` (`car`, `due_date`)"],
|
|
// Read as "this car's checks, newest first" every time.
|
|
technical_checks: ["CREATE INDEX `idx_technical_checks_car_date` ON `technical_checks` (`car`, `date`)"],
|
|
// A charger is looked up by its owner, and by serial when checking whether the
|
|
// account it came from has already been imported.
|
|
home_chargers: ["CREATE INDEX `idx_home_chargers_owner_serial` ON `home_chargers` (`owner`, `serial`)"],
|
|
// The runner sweeps every enabled task on every tick, and the page reads one
|
|
// owner's; both go through these two columns.
|
|
charging_tasks: [
|
|
"CREATE INDEX `idx_charging_tasks_owner_enabled` ON `charging_tasks` (`owner`, `enabled`)",
|
|
],
|
|
// Audit is queried "this charger's events, newest first" and "this user's events".
|
|
control_audit: [
|
|
"CREATE INDEX `idx_control_audit_serial_created` ON `control_audit` (`serial`, `created`)",
|
|
"CREATE INDEX `idx_control_audit_user_created` ON `control_audit` (`user_id`, `created`)",
|
|
],
|
|
};
|
|
|
|
async function main() {
|
|
console.log(`Connecting to ${PB_URL} ...`);
|
|
const token = await authenticate();
|
|
console.log("Authenticated as superuser.");
|
|
|
|
let collections = await listCollections(token);
|
|
const format = detectFormat(collections);
|
|
console.log(`Schema format: "${format}"`);
|
|
|
|
const idByName = {};
|
|
for (const c of collections) idByName[c.name] = c.id;
|
|
|
|
// Create in dependency order (organizations before users references it; cars
|
|
// before its relations; "users" already exists as PocketBase's built-in auth
|
|
// collection, so it's never created here — only reconciled below).
|
|
for (const name of [
|
|
"app_settings",
|
|
"organizations",
|
|
"cars",
|
|
"service_records",
|
|
"technical_checks",
|
|
"parts",
|
|
"car_shares",
|
|
"fuel_entries",
|
|
"charging_sessions",
|
|
"maintenance_entries",
|
|
"car_documents",
|
|
"reminders",
|
|
"control_audit",
|
|
"home_chargers",
|
|
"charging_tasks",
|
|
]) {
|
|
if (collections.some((c) => c.name === name)) continue;
|
|
await createCollection(token, name, DESIRED[name], format, idByName);
|
|
console.log(`✓ ${name} — created`);
|
|
// Refresh so later relations can reference newly-created collection ids.
|
|
collections = await listCollections(token);
|
|
for (const c of collections) idByName[c.name] = c.id;
|
|
}
|
|
|
|
// Reconcile fields on existing collections (add missing + fix relation options
|
|
// and select values — this is what grows users.role to include "superadmin"
|
|
// and adds users.organization on an existing deployment).
|
|
for (const name of [
|
|
"app_settings",
|
|
"organizations",
|
|
"users",
|
|
"cars",
|
|
"service_records",
|
|
"technical_checks",
|
|
"parts",
|
|
"car_shares",
|
|
"fuel_entries",
|
|
"charging_sessions",
|
|
"maintenance_entries",
|
|
"car_documents",
|
|
"reminders",
|
|
"control_audit",
|
|
"home_chargers",
|
|
"charging_tasks",
|
|
]) {
|
|
await reconcileFields(token, name, DESIRED[name], format, idByName);
|
|
}
|
|
|
|
console.log(
|
|
"\nDone. Collections ready: app_settings, organizations, users, cars,\n" +
|
|
"service_records,\n" +
|
|
"technical_checks, parts, car_shares, fuel_entries, charging_sessions,\n" +
|
|
"maintenance_entries, car_documents, reminders, control_audit, home_chargers,\n" +
|
|
"charging_tasks.",
|
|
);
|
|
console.log(
|
|
"Note: the legacy `sessions` collection is no longer used (auth moved to PocketBase\n" +
|
|
"tokens). It is left in place rather than dropped — delete it by hand if you want.",
|
|
);
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error("\nSetup failed:", err.message);
|
|
process.exit(1);
|
|
});
|