Files
DriverVault/API Server/internal/bootstrap/schema.go
T
tajniak81andClaude Opus 5 35e6c511b7 Changed parts: the list is the car's, not the app's
The Changed parts section offered all three parts to every car. An EV changes no
oil, and a checkbox nobody will ever tick is one more thing to read past on every
service — so which parts a car records now belongs to the car, the same way its
tabs, its Information rows and its Service history columns already do.

It works the way those three do because a fourth mechanism for the same idea
would be a fourth to keep in step: hidden_service_parts on the car, validated by
the endpoint that already does this, stored as the hidden set so a part added in
a later release is on by default, and needing write access because the choice
belongs to the car and everyone it is shared with sees it.

There is no order beside it, which is the one place this departs from the other
three. Those arrange things whose position means something — a tab bar reads left
to right, a table's columns are read across. The parts are a checkbox list inside
a single column, and moving Cabin air filter above Oil says nothing. Adding one
later is the same shape as the others if that turns out to be wrong.

A part switched off leaves the form and the history together — the chips on the
phone's cards, the web column's summary and the panel it opens. "I don't record
this" means it stops taking up room, not that it takes up room saying nothing,
which is the rule a hidden column already follows. That is the judgment call
here: a car with five years of oil changes hides them all by switching the part
off. Nothing is written to the records, so switching it back on brings every one
of those chips back, which is what makes the call safe to reverse.

The part that would have been a silent data bug: the API rewrites all three
booleans from the body of a service update, so a form that simply stopped
sending a hidden part would set it false on the next edit of any old record.
Both forms therefore keep every part in their state and submit every one — only
the checkboxes are filtered. The mirror of that is a *new* record, where a hidden
part starts false rather than at its `initial`, since ticking a box nobody was
shown is not a default, it's a guess. Oil is the only part with initial: true, so
that case is live the moment anyone hides it.

Verified: go vet and go test ./... pass, with a new test covering that every part
is hideable (unlike the tabs and the columns — a service that changed nothing is
a real service), that the "parts" column key is refused as a part key and a part
key as a column key, and that no part is also a column. flutter analyze is clean
and flutter test passes 32 to 35, the new ones covering visibleParts, that a
hidden part's chips go while its stored boolean stays, and the picker's fourth
section. npm run build is clean.

Both apps were driven against throwaway stub APIs. Web: the picker saved
{"hiddenServiceParts":["oil"]}, the table's parts cell went from "Oil & Oil
filter +2" to "Engine air filter, Cabin air filter", the record whose only part
was oil went to an empty cell, the panel dropped to two rows, the add form
offered two unticked boxes where oil's initial: true would have ticked one, and
editing the three-part record sent changedOil:true back with a box that was never
on screen. Phone: the same car rendered chips "Engine air, Cabin air", "Changed
parts —" for the oil-only record, and an add sheet with exactly two unticked
boxes.

Not verified: no automated test guards the web behaviour — the web app still has
no test runner, so the above was read out of the live DOM and the outgoing
request bodies by hand. The phone's picker was checked by widget test and by
rendering, but its Save was not driven end to end. Neither app was run against
the real API Server: bootstrap appends the new field on the next start, and until
that start a client sending hiddenServiceParts takes a 400 — they deploy together
from this repo, but the server must go first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:30:08 +02:00

286 lines
11 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),
},
// 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),
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),
},
}
// 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",
}
// 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",
}
// 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`)"},
"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`)",
},
}