Files
DriverVault/API Server/internal/bootstrap/schema.go
T
tajniak81andClaude Opus 5 5a4515978f One schedule for every charger, and a clock on the server to keep it
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>
2026-09-03 22:47:30 +02:00

357 lines
15 KiB
Go

package bootstrap
// This file is the Go mirror of the DESIRED schema, create order, reconcile
// order and INDEXES in scripts/setup-pocketbase.mjs. Keep the two in sync: edit
// here and re-run the server (bootstrap applies on startup), or run the script.
//
// Access rules are left null on every collection on purpose — every client goes
// through the API Server, which authenticates as a superuser, so the database is
// never exposed directly (including attachments, proxied by the API).
// collectionsSchema is the desired field set per collection.
var collectionsSchema = map[string][]fieldDef{
"cars": {
fText("name", true),
fText("make", false),
fText("model", false),
fNumber("year"),
fText("registration", false),
fText("registration_country", false),
fText("vin", false),
fNumber("service_interval_days"),
fNumber("service_interval_km"),
// Roadworthiness inspection cycle. Only prefills a check's next-due date.
fNumber("technical_check_interval_days"),
fText("oil_spec", false),
fText("transmission_oil_spec", false),
fText("differential_oil_spec", false),
fText("brake_fluid_spec", false),
fText("coolant_spec", false),
fNumber("current_km"),
// Bi-fuel LPG conversions are their own choice: the car runs on either tank.
fSelect("fuel_type", []string{
"petrol", "petrol_lpg", "diesel", "diesel_lpg", "hybrid", "electric", "hydrogen",
}, false),
// ISO 8601 reduced precision: "2015", "2015-03" or "2015-03-10". A build
// date is often only half known; see normalizeBuildDate in api/cars.go.
fText("build_date", false),
fText("first_registration_date", false), // ISO YYYY-MM-DD
// Link to the manufacturer service this car came from: the plugin name plus
// that plugin's own id for the vehicle (the VIN, for Toyota). See
// internal/api/vehicleproviders.go. Blank for a hand-entered car.
fText("provider", false),
fText("provider_vehicle_id", false),
// What this car's page shows: the tabs switched off (["fuel"] on an EV)
// and the Information fields switched off (["differentialOil"] on a car
// without one). Properties of the car, so everyone it is shared with sees
// the same page. Stored as the hidden sets, so anything added in a later
// release is on by default. Keys are validated in internal/api/cars.go.
fJSON("hidden_tabs", 2000),
fJSON("hidden_fields", 2000),
// And the columns of the Service history table (["parts"] for a reader who
// never records what was changed). Date is not hideable and so never
// appears here.
fJSON("hidden_service_columns", 2000),
// And the parts its services never change (["oil"] on an EV), which come
// off the service form and out of that column together.
fJSON("hidden_service_parts", 2000),
// The order the tabs are laid out in, as tab keys, the same for the
// Information rows, the Service history columns, and the connected
// service's headline readings. Empty means the page's own default order.
fJSON("tab_order", 2000),
fJSON("field_order", 2000),
fJSON("service_column_order", 2000),
fJSON("metric_order", 2000),
// Owner of this car. Non-cascading: deleting a user must not wipe their cars.
fRelation("owner", "users", false, false),
},
"service_records": {
fRelation("car", "cars", true, true),
fDate("date", true),
fNumber("km"),
fBool("changed_oil"),
fBool("changed_engine_air_filter"),
fBool("changed_cabin_air_filter"),
fText("notes", false),
attachment(), // the workshop receipt / stamped service-book page
},
// Mandatory roadworthiness inspections (przegląd techniczny / MOT / TÜV).
"technical_checks": {
fRelation("car", "cars", true, true),
fDate("date", true),
fSelect("result", []string{"passed", "failed"}, false),
fNumber("cost"),
fText("station", false),
fDate("valid_until", false),
fText("notes", false),
attachment(), // the certificate
},
"parts": {
fRelation("car", "cars", true, true),
fText("name", true),
fText("part_number", false),
fText("category", false),
fText("notes", false),
attachment(), // a photo of the box, or the part's spec sheet
},
// Fuel refills. Consumption is derived on read, not stored.
"fuel_entries": {
fRelation("car", "cars", true, true),
fDate("date", true),
fNumber("km"), // odometer at the pump
fNumber("liters"),
fNumber("cost"),
fBool("full_tank"),
fBool("missed_fill"),
fText("station", false),
fText("notes", false),
attachment(), // the pump receipt
},
// Charging sessions for an electric car — the EV counterpart of fuel_entries,
// same shape so the two logs behave alike. Consumption is derived on read.
"charging_sessions": {
fRelation("car", "cars", true, true),
fDate("date", true),
fNumber("km"), // odometer when plugging in
fNumber("kwh"),
fNumber("cost"),
fBool("full_charge"),
fBool("missed_session"),
fText("location", false), // "Home", "Ionity Køge"
fText("notes", false),
attachment(), // the charge point's receipt
},
// Workshop visits and repairs (unplanned/one-off garage work with a labour bill).
"maintenance_entries": {
fRelation("car", "cars", true, true),
fDate("date", true),
fNumber("km"),
fSelect("type", []string{"repair", "inspection", "bodywork", "tyres", "diagnostics", "recall", "warranty", "other"}, false),
fSelect("status", []string{"scheduled", "in_progress", "completed"}, false),
fText("workshop", false),
fText("location", false),
fText("description", false),
fText("parts_used", false),
fNumber("labor_cost"),
fNumber("parts_cost"),
fText("invoice_number", false),
fDate("warranty_until", false),
fText("notes", false),
attachment(), // the workshop's invoice
},
// Insurance, pollution certificates, registration papers … expiry drives reminders.
"car_documents": {
fRelation("car", "cars", true, true),
fSelect("type", []string{"insurance", "pollution", "registration", "inspection", "roadTax", "warranty", "other"}, false),
fText("title", true),
fText("provider", false),
fText("reference", false),
fDate("issue_date", false),
fDate("expiry_date", false),
fNumber("cost"),
fText("notes", false),
attachment(), // the scan/PDF of the paperwork
},
// User-set reminders (derived document/service ones are computed on read).
"reminders": {
fRelation("car", "cars", true, true),
fText("title", true),
fSelect("type", []string{"maintenance", "document", "service", "inspection", "other"}, false),
fDate("due_date", false),
fNumber("due_km"),
fNumber("repeat_days"),
fNumber("repeat_km"),
fBool("done"),
fDate("done_at", false),
fText("notes", false),
},
// Per-car sharing grants. Cascades on both relations.
"car_shares": {
fRelation("car", "cars", true, true),
fRelation("user", "users", true, true),
fSelect("permission", []string{"read", "write"}, true),
fAutodate("created", true, false),
},
// Append-only audit trail for OCPP charger control. Actor/org stored as plain
// text ids (not relations) so the trail survives user or org deletion.
"control_audit": {
fText("user_id", false),
fText("org_id", false),
fText("serial", false),
fText("action", true),
fText("result", false),
fJSON("params", 10000),
fAutodate("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 (L2) and user (L3) layers keep their own
// plugin config in a field of the same name below, so the global layer is
// stored the way they are instead of in a file beside the binary.
"app_settings": {
fText("key", true),
fJSON("pluginSettings", 200000),
},
// Tenants that users belong to.
"organizations": {
fText("name", true),
fAutodate("created", true, false),
// Per-organization plugin/integration config (middle layer of the cascade).
fJSON("pluginSettings", 100000),
},
// 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.
"home_chargers": {
fText("name", true),
fText("serial", false),
fText("vendor", false), // "Anker Solix", "Greencell" — who makes it
fText("model", false), // "A5191"
fText("site_name", false),
fNumber("power_kw"),
fText("connector", false),
// Where this charger came from: the charger-provider id plus that
// provider's own id for it (the serial, for both providers we speak to).
// Blank for one added by hand. See internal/api/chargerproviders.go.
fText("provider", false),
fText("provider_charger_id", false),
// Owner. Non-cascading, like a car's: deleting a user must not silently
// wipe the records they own.
fRelation("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.
fAutodate("created", true, false),
},
// The home-charger scheduler: the 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 internal/api/chargingtasks_run.go.
"charging_tasks": {
fText("name", true),
// The home_chargers rows this task acts on. Stored as 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 — and a
// multi-relation would have to be rewritten on every import to say it.
fJSON("chargers", 2000),
fSelect("action", []string{"start", "stop", "limit", "boost"}, true),
fNumber("amps"), // the ceiling, for the "limit" action
// 24-hour "HH:MM", read in the IANA zone the task was written in. The
// server's clock is not the one the user set the time by.
fText("time", true),
fText("zone", false),
fJSON("days", 200), // 0=Sunday … 6=Saturday; empty means every day
fBool("enabled"),
// The outcome of the last firing, so a task that has been failing quietly
// says so in the list. last_run is also the guard against firing twice in
// the same minute.
fText("last_run", false), // RFC3339, UTC
fText("last_result", false),
// Owner. Non-cascading, like a charger's.
fRelation("owner", "users", false, false),
fAutodate("created", true, false),
},
// Custom fields layered onto the built-in "users" auth collection.
"users": {
fText("bio", false),
fSelect("theme", []string{"light", "dark", "system"}, false),
fText("locale", false),
fSelect("date_format", []string{"YMD", "DMY_NUM", "DMY", "MDY"}, false),
// "auto" is the region's own convention, which is what every clock in the
// app read before this field existed.
fSelect("time_format", []string{"auto", "24", "12"}, false),
fSelect("currency", []string{
"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",
}, false),
fSelect("font_size", []string{"small", "medium", "large"}, false),
// 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 the garage order.
fBool("drag_locked"),
fDate("deletion_requested_at", false),
// Access role. Empty value is treated as "user" by the API.
fSelect("role", []string{"user", "admin", "superadmin"}, false),
// Organization membership. Non-cascading: deleting an org keeps its people.
fRelation("organization", "organizations", false, false),
// Per-user plugin/integration config (bottom layer of the cascade).
fJSON("pluginSettings", 100000),
// The garage order: car ids in the order this user arranged them. Per
// user rather than per car, so it also covers cars shared with them and
// never reorders somebody else's garage.
fJSON("car_order", 20000),
// The charging page's tab order. Per user like the garage order, and for
// the same reason: it is this person's arrangement of their own page.
fJSON("charger_tab_order", 2000),
// The charging page's card order, stored the same way as its tab order.
fJSON("charger_card_order", 2000),
},
}
// createOrder is the dependency order for creating missing collections.
// "users" is PocketBase's built-in auth collection and is never created here.
var createOrder = []string{
"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",
}
// reconcileOrder additionally includes "users" so its custom fields (role,
// organization, preferences) are added to the built-in collection.
var reconcileOrder = []string{
"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",
}
// indexes are extra SQL indexes applied at collection-create time.
var indexes = map[string][]string{
// 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`)"},
"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`)"},
"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`)"},
"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`)",
},
}