Files
DriverVault/API Server/internal/bootstrap/schema.go
T
tajniak81andClaude Opus 5 b60d929ed6 Service history: columns you can switch off and rearrange
A car's page has let you choose and arrange two things for a while - which tabs
it shows, and which rows the Information tab lists, both dragged into whatever
order you like. The Service history table was left out of that: nine columns,
hardcoded, in one order, for every car. An EV shows Oil & Oil filter and Engine
air filter on every row of a history that will never record either, and a reader
who mostly wants Notes has to look past four columns of dates and distances to
reach it.

It works the way the other two do, because a third mechanism for the same idea
would be one to keep in step. Both lists are properties of the car, so everyone
it is shared with sees the same table, and both need write access to set. The
columns are stored as the hidden set rather than the visible one, so a column
added in a later release is on by default. The arrangement covers the hidden
columns too, which is what makes a column switched back on return to where it
was instead of reappearing at the end - verified below, since that is the part
of this shape that is easy to get wrong and invisible until somebody hits it.

Date cannot be switched off. Every row of that table is work done on a day, and
a history with the day taken out stops being a history; it can still be dragged
anywhere, which is exactly the rule Information already follows in the tab bar.
That is a judgment call and the annotation that prompted this only circled the
other eight columns - moving "date" into hideableServiceColumns and dropping the
filter in the picker would reverse it in two lines if it turns out to be wrong.

Server: hidden_service_columns and service_column_order on the car, validated
against their own key sets by the endpoint that already does this for tabs,
fields and readings. The arrangeable set is derived from the hideable one plus
the date rather than written out again, so the two cannot drift as columns are
added. Bootstrap appends missing fields to existing collections, so the two
columns appear on the next server start with no migration to run.

Web: the table stopped being nine hardcoded th/td pairs and is now driven by one
list of columns, head and body from the same source, which is what stops a moved
or hidden column from shifting the headings out of line with the cells. The
cells are built a row at a time rather than a call per cell, so a long history
doesn't rebuild every cell three times to read its text, its classes and whether
it is the file column. The column headings kept their existing car.services.col*
translations - the keys are mapped rather than derived, because renaming a dozen
strings in three languages to save a lookup table would be the wrong trade. Four
new strings in all three languages.

Verified: go vet and go test ./... pass, with new tests covering both key sets -
that hiding the date is refused, that a field key is not a column key, and that
the arrangeable set is the hideable one plus the date. npm run build is clean.
The page itself was driven in a browser against a throwaway stub API: the
rewritten table renders identically to the hardcoded one, switching two columns
off removed exactly those two from head and body with the rest still aligned and
sent {"hiddenServiceColumns":["oil","engineFilter"]}, dragging Notes onto Km
reordered head and body live and saved an order with the hidden columns still
holding their places, switching Oil back on returned it between Next km and
Cabin air filter rather than to the end, and a read-only share gets no gear
button, no draggable headings and no drag hint.

Not verified: the drag was exercised by dispatching drag events at the
component's own handlers, not by a pointer - the browser pane was not
compositing, which rules out both screenshots and a real drag - so the native
drag image and cursor are unchecked. No automated test guards any of the web
behaviour; the web app still has no test runner. The API rejects unknown JSON
fields, so this web build against an older API Server would take a 400 when
saving the picker: they deploy together from this repo, but one must not ship
without the other. The phone app is deliberately untouched, having no column
table to arrange, and ignores both new fields.

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

280 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),
fText("build_date", false), // ISO YYYY-MM-DD (date-only)
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 (["oil"] on an EV, which
// has no oil to change). Date is not hideable and so never appears here.
fJSON("hidden_service_columns", 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`)",
},
}