Files
DriverVault/API Server/scripts/setup-pocketbase.mjs
T
2026-07-06 08:50:52 +02:00

312 lines
11 KiB
JavaScript

// Idempotent PocketBase schema setup for the Car Control project.
//
// Creates three collections — cars, service_records, parts — matching the
// original "Car Service.xlsx". 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.
//
// 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 }),
};
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;
}
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;
}
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,
};
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(", ")}`);
}
// 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"),
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"),
F.select("fuel_type", ["petrol", "diesel", "hybrid", "electric"]),
F.text("build_date"), // ISO YYYY-MM-DD (date-only; VIN 10th digit ≈ model year)
F.text("first_registration_date"), // ISO YYYY-MM-DD
// 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"),
],
parts: [
F.relation("car", "cars", true),
F.text("name", true),
F.text("part_number"),
F.text("category"),
],
// 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),
],
// 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"]),
F.select("font_size", ["small", "medium", "large"]),
F.date("deletion_requested_at"),
// Access role. Empty value is treated as "user" by the API.
F.select("role", ["user", "admin"]),
],
// One row per issued login token, so "active sessions" can be listed and
// individually revoked without needing a stateful session store elsewhere.
sessions: [
F.relation("user", "users", true),
F.text("jti", true),
F.text("device_label"),
F.text("ip"),
F.text("user_agent"),
F.bool("revoked"),
F.date("expires_at", true),
F.autodate("created", true, false),
],
};
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 (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 ["cars", "service_records", "parts", "sessions", "car_shares"]) {
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).
for (const name of ["users", "cars", "service_records", "parts", "sessions", "car_shares"]) {
await reconcileFields(token, name, DESIRED[name], format, idByName);
}
console.log("\nDone. Collections ready: users, cars, service_records, parts, sessions, car_shares.");
}
main().catch((err) => {
console.error("\nSetup failed:", err.message);
process.exit(1);
});