312 lines
11 KiB
JavaScript
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);
|
|
});
|