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>
488 lines
16 KiB
Go
488 lines
16 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
|
|
"drivervault/apiserver/internal/models"
|
|
)
|
|
|
|
// Access levels a user can have on a car. accessNone means no access at all.
|
|
const (
|
|
accessNone = ""
|
|
accessRead = "read"
|
|
accessWrite = "write"
|
|
accessOwner = "owner"
|
|
)
|
|
|
|
// carAccessLevel reports the requesting user's permission on a car: "owner" if
|
|
// they own it, otherwise the permission from any car_shares grant ("read"/
|
|
// "write"), otherwise "" (no access). It also returns the car record so callers
|
|
// that already need it avoid a second fetch.
|
|
func (s *Server) carAccessLevel(ctx context.Context, userID, carID string) (string, *carRecord, error) {
|
|
var rec carRecord
|
|
if err := s.pb.GetOne(ctx, colCars, carID, &rec); err != nil {
|
|
return accessNone, nil, err
|
|
}
|
|
if rec.Owner == userID {
|
|
return accessOwner, &rec, nil
|
|
}
|
|
perm, err := s.sharePermission(ctx, carID, userID)
|
|
if err != nil {
|
|
return accessNone, &rec, err
|
|
}
|
|
return perm, &rec, nil
|
|
}
|
|
|
|
// sharePermission returns the permission ("read"/"write") granted to userID on
|
|
// carID via car_shares, or "" if there is no grant.
|
|
func (s *Server) sharePermission(ctx context.Context, carID, userID string) (string, error) {
|
|
res, err := s.pb.List(ctx, colShares, url.Values{
|
|
"filter": {fmt.Sprintf("car='%s' && user='%s'", carID, userID)},
|
|
"perPage": {"1"},
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var recs []shareRecord
|
|
if err := json.Unmarshal(res.Items, &recs); err != nil || len(recs) == 0 {
|
|
return "", err
|
|
}
|
|
return recs[0].Permission, nil
|
|
}
|
|
|
|
func canWrite(level string) bool { return level == accessOwner || level == accessWrite }
|
|
|
|
// requireCarAccess enforces that the current user's access to carID meets the
|
|
// minimum `need` (accessRead = any access, accessWrite = write or owner,
|
|
// accessOwner = owner only). On failure it writes the HTTP response and returns
|
|
// false, so callers can `if !s.requireCarAccess(...) { return }`.
|
|
func (s *Server) requireCarAccess(w http.ResponseWriter, r *http.Request, carID, need string) bool {
|
|
if carID == "" {
|
|
writeError(w, http.StatusBadRequest, "car is required")
|
|
return false
|
|
}
|
|
level, _, err := s.carAccessLevel(r.Context(), s.currentUserID(r), carID)
|
|
if err != nil {
|
|
writePBError(w, err)
|
|
return false
|
|
}
|
|
var ok bool
|
|
switch need {
|
|
case accessWrite:
|
|
ok = canWrite(level)
|
|
case accessOwner:
|
|
ok = level == accessOwner
|
|
default: // accessRead / any
|
|
ok = level != accessNone
|
|
}
|
|
if !ok {
|
|
writeError(w, http.StatusForbidden, "you do not have access to this car")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (s *Server) listCars(w http.ResponseWriter, r *http.Request) {
|
|
me := s.currentUserID(r)
|
|
|
|
// Cars the user owns.
|
|
ownedRes, err := s.pb.List(r.Context(), colCars, url.Values{
|
|
"filter": {fmt.Sprintf("owner='%s'", me)},
|
|
"sort": {"name"},
|
|
"perPage": {"200"},
|
|
})
|
|
if err != nil {
|
|
writePBError(w, err)
|
|
return
|
|
}
|
|
var owned []carRecord
|
|
if err := json.Unmarshal(ownedRes.Items, &owned); err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
out := make([]models.Car, 0, len(owned))
|
|
for _, rec := range owned {
|
|
m := rec.toModel()
|
|
m.Access = accessOwner
|
|
out = append(out, m)
|
|
}
|
|
|
|
// Cars shared with the user (each grant → fetch the car, annotate access).
|
|
sharesRes, err := s.pb.List(r.Context(), colShares, url.Values{
|
|
"filter": {fmt.Sprintf("user='%s'", me)},
|
|
"perPage": {"200"},
|
|
})
|
|
if err != nil {
|
|
writePBError(w, err)
|
|
return
|
|
}
|
|
var shares []shareRecord
|
|
if err := json.Unmarshal(sharesRes.Items, &shares); err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
for _, sh := range shares {
|
|
var rec carRecord
|
|
if err := s.pb.GetOne(r.Context(), colCars, sh.Car, &rec); err != nil {
|
|
continue // grant points at a deleted car; skip defensively
|
|
}
|
|
m := rec.toModel()
|
|
m.Access = sh.Permission
|
|
out = append(out, m)
|
|
}
|
|
|
|
// Hand the garage back in the order the user arranged it. Best effort: if the
|
|
// profile can't be read, the default order (owned by name, then shared) still
|
|
// renders a usable garage.
|
|
if rec, err := s.fetchUser(r, me); err == nil {
|
|
applyCarOrder(out, rec.carOrder())
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
// applyCarOrder sorts cars into the user's arranged order, in place. Cars the
|
|
// arrangement doesn't mention — a car added or shared since the last drag — keep
|
|
// their relative order and follow the arranged ones, so a new car shows up at
|
|
// the end rather than jumping into the middle.
|
|
func applyCarOrder(cars []models.Car, order []string) {
|
|
if len(order) == 0 || len(cars) < 2 {
|
|
return
|
|
}
|
|
rank := make(map[string]int, len(order))
|
|
for i, id := range order {
|
|
rank[id] = i
|
|
}
|
|
sort.SliceStable(cars, func(i, j int) bool {
|
|
ri, oki := rank[cars[i].ID]
|
|
rj, okj := rank[cars[j].ID]
|
|
if oki != okj {
|
|
return oki // an arranged car sorts before an unarranged one
|
|
}
|
|
if !oki {
|
|
return false // both unarranged: leave them as they are
|
|
}
|
|
return ri < rj
|
|
})
|
|
}
|
|
|
|
func (s *Server) getCar(w http.ResponseWriter, r *http.Request) {
|
|
level, rec, err := s.carAccessLevel(r.Context(), s.currentUserID(r), r.PathValue("id"))
|
|
if err != nil {
|
|
writePBError(w, err)
|
|
return
|
|
}
|
|
if level == accessNone {
|
|
writeError(w, http.StatusForbidden, "you do not have access to this car")
|
|
return
|
|
}
|
|
m := rec.toModel()
|
|
m.Access = level
|
|
writeJSON(w, http.StatusOK, m)
|
|
}
|
|
|
|
func (s *Server) createCar(w http.ResponseWriter, r *http.Request) {
|
|
var in models.Car
|
|
if err := decodeJSON(r, &in); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
if in.Name == "" {
|
|
writeError(w, http.StatusBadRequest, "name is required")
|
|
return
|
|
}
|
|
applyCarDefaults(&in)
|
|
|
|
// Owner is always the authenticated user; ignore any client-supplied owner.
|
|
payload := carPayload(in)
|
|
payload["owner"] = s.currentUserID(r)
|
|
|
|
var rec carRecord
|
|
if err := s.pb.Create(r.Context(), colCars, payload, &rec); err != nil {
|
|
writePBError(w, err)
|
|
return
|
|
}
|
|
m := rec.toModel()
|
|
m.Access = accessOwner
|
|
writeJSON(w, http.StatusCreated, m)
|
|
}
|
|
|
|
func (s *Server) updateCar(w http.ResponseWriter, r *http.Request) {
|
|
var in models.Car
|
|
if err := decodeJSON(r, &in); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
level, _, err := s.carAccessLevel(r.Context(), s.currentUserID(r), r.PathValue("id"))
|
|
if err != nil {
|
|
writePBError(w, err)
|
|
return
|
|
}
|
|
if !canWrite(level) {
|
|
writeError(w, http.StatusForbidden, "you cannot edit this car")
|
|
return
|
|
}
|
|
// carPayload deliberately omits owner, so a PATCH never reassigns ownership.
|
|
var rec carRecord
|
|
if err := s.pb.Update(r.Context(), colCars, r.PathValue("id"), carPayload(in), &rec); err != nil {
|
|
writePBError(w, err)
|
|
return
|
|
}
|
|
m := rec.toModel()
|
|
m.Access = level
|
|
writeJSON(w, http.StatusOK, m)
|
|
}
|
|
|
|
// hideableCarTabs are the car-detail tabs that can be switched off. "info" is
|
|
// deliberately absent: it is the car itself, and a page with no tabs left would
|
|
// be a dead end.
|
|
var hideableCarTabs = map[string]bool{
|
|
"provider": true, "services": true, "technical": true, "maintenance": true,
|
|
"fuel": true, "charging": true, "documents": true, "parts": true, "reminders": true,
|
|
}
|
|
|
|
// arrangeableCarTabs are the tabs a car's page can be rearranged into, which is
|
|
// the hideable ones plus Information: it cannot be switched off, but there is no
|
|
// reason it has to stay at the front. Derived from hideableCarTabs so the two
|
|
// sets cannot drift as tabs are added.
|
|
var arrangeableCarTabs = func() map[string]bool {
|
|
out := make(map[string]bool, len(hideableCarTabs)+1)
|
|
for key := range hideableCarTabs {
|
|
out[key] = true
|
|
}
|
|
out["info"] = true
|
|
return out
|
|
}()
|
|
|
|
// hideableCarFields are the Information rows that can be switched off — every
|
|
// one of them, since unlike the tabs there is no row the page needs to keep.
|
|
// Mirrors the car.info.* labels the web app renders.
|
|
var hideableCarFields = map[string]bool{
|
|
"oilSpec": true, "transmissionOil": true, "differentialOil": true,
|
|
"brakeFluid": true, "coolant": true, "odometer": true, "serviceInterval": true,
|
|
"nextDue": true, "registrationPlate": true, "registrationCountry": true,
|
|
"vin": true, "fuelType": true, "buildDate": true, "firstRegistration": true,
|
|
}
|
|
|
|
// hideableServiceColumns are the columns of the Service history table that can
|
|
// be switched off. Date is deliberately not among them: every row of that table
|
|
// is a service that happened on a day, and a history with the day taken out
|
|
// stops being a history. Mirrors the car.services.col* labels the web app
|
|
// renders.
|
|
var hideableServiceColumns = map[string]bool{
|
|
"km": true, "nextDate": true, "nextKm": true, "oil": true,
|
|
"engineFilter": true, "cabinFilter": true, "notes": true, "file": true,
|
|
}
|
|
|
|
// arrangeableServiceColumns are the columns that table can be rearranged into:
|
|
// the hideable ones plus Date, which cannot be switched off but has no reason to
|
|
// be stuck at the left. Derived from hideableServiceColumns so the two sets
|
|
// cannot drift as columns are added — the same construction arrangeableCarTabs
|
|
// uses for Information.
|
|
var arrangeableServiceColumns = func() map[string]bool {
|
|
out := make(map[string]bool, len(hideableServiceColumns)+1)
|
|
for key := range hideableServiceColumns {
|
|
out[key] = true
|
|
}
|
|
out["date"] = true
|
|
return out
|
|
}()
|
|
|
|
// arrangeableCarMetrics are the headline readings on the connected-service tab,
|
|
// and so the keys a car's arrangement of them may name. Derived from the reading
|
|
// specs in vehicleproviders.go rather than written out again, so the set cannot
|
|
// drift from what that panel actually shows.
|
|
var arrangeableCarMetrics = func() map[string]bool {
|
|
out := make(map[string]bool, len(headlineMetricSpecs)+len(unmeasuredMetricKeys))
|
|
for _, spec := range headlineMetricSpecs {
|
|
out[spec.key] = true
|
|
}
|
|
for _, key := range unmeasuredMetricKeys {
|
|
out[key] = true
|
|
}
|
|
return out
|
|
}()
|
|
|
|
// normalizeKeys validates a list of tab or field keys against the keys that
|
|
// exist, trimming blanks and duplicates. Unknown keys are rejected rather than
|
|
// ignored: they can only come from a stale or wrong client, and dropping them
|
|
// silently would hide the mistake while the page carried on as before.
|
|
func normalizeKeys(in []string, allowed map[string]bool, what string) ([]string, error) {
|
|
out := make([]string, 0, len(in))
|
|
seen := make(map[string]bool, len(in))
|
|
for _, key := range in {
|
|
key = strings.TrimSpace(key)
|
|
if key == "" || seen[key] {
|
|
continue
|
|
}
|
|
if !allowed[key] {
|
|
return nil, fmt.Errorf("%q is not a car %s", key, what)
|
|
}
|
|
seen[key] = true
|
|
out = append(out, key)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// PUT /api/cars/{id}/view — choose what this car's page shows: which tabs, which
|
|
// rows of the Information tab, which columns of the Service history table, and
|
|
// the order the tabs, the Information rows, those columns and the connected
|
|
// service's headline readings are laid out in. Body: {hiddenTabs?: [...],
|
|
// hiddenFields?: [...], hiddenServiceColumns?: [...], tabOrder?: [...],
|
|
// fieldOrder?: [...], serviceColumnOrder?: [...], metricOrder?: [...]}; only the
|
|
// lists present are written, so a client can rearrange one group without
|
|
// resending the others. Its own endpoint rather than fields on the car edit, so an ordinary
|
|
// save of the car form — which sends every other field — can never reveal
|
|
// something somebody deliberately switched off. Needs write access: the choice
|
|
// belongs to the car, so it is the same permission as editing it.
|
|
func (s *Server) updateCarView(w http.ResponseWriter, r *http.Request) {
|
|
var in struct {
|
|
HiddenTabs *[]string `json:"hiddenTabs"`
|
|
HiddenFields *[]string `json:"hiddenFields"`
|
|
HiddenServiceColumns *[]string `json:"hiddenServiceColumns"`
|
|
TabOrder *[]string `json:"tabOrder"`
|
|
FieldOrder *[]string `json:"fieldOrder"`
|
|
ServiceColumnOrder *[]string `json:"serviceColumnOrder"`
|
|
MetricOrder *[]string `json:"metricOrder"`
|
|
}
|
|
if err := decodeJSON(r, &in); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
level, _, err := s.carAccessLevel(r.Context(), s.currentUserID(r), r.PathValue("id"))
|
|
if err != nil {
|
|
writePBError(w, err)
|
|
return
|
|
}
|
|
if !canWrite(level) {
|
|
writeError(w, http.StatusForbidden, "you cannot edit this car")
|
|
return
|
|
}
|
|
|
|
payload := map[string]any{}
|
|
if in.HiddenTabs != nil {
|
|
tabs, err := normalizeKeys(*in.HiddenTabs, hideableCarTabs, "tab")
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
payload["hidden_tabs"] = tabs
|
|
}
|
|
if in.HiddenFields != nil {
|
|
fields, err := normalizeKeys(*in.HiddenFields, hideableCarFields, "field")
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
payload["hidden_fields"] = fields
|
|
}
|
|
if in.HiddenServiceColumns != nil {
|
|
columns, err := normalizeKeys(*in.HiddenServiceColumns, hideableServiceColumns, "service column")
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
payload["hidden_service_columns"] = columns
|
|
}
|
|
if in.TabOrder != nil {
|
|
// A wider set than the hidden tabs: Information is arrangeable although it
|
|
// cannot be switched off. A partial list is accepted, and the tabs it
|
|
// leaves out follow the arranged ones — which is what puts a tab added in
|
|
// a later release at the end rather than in the middle of somebody's bar.
|
|
order, err := normalizeKeys(*in.TabOrder, arrangeableCarTabs, "tab")
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
payload["tab_order"] = order
|
|
}
|
|
if in.FieldOrder != nil {
|
|
// The same key set as the hidden fields, since every Information row can
|
|
// be moved. A partial list is accepted rather than demanding all 14: the
|
|
// rows it leaves out follow the arranged ones, which is also what makes a
|
|
// row added in a later release land at the end instead of the middle.
|
|
order, err := normalizeKeys(*in.FieldOrder, hideableCarFields, "field")
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
payload["field_order"] = order
|
|
}
|
|
if in.ServiceColumnOrder != nil {
|
|
// A wider set than the hidden columns, for the same reason the tab order
|
|
// is: Date is arrangeable although it cannot be switched off. A partial
|
|
// list is accepted, and the columns it leaves out follow the arranged
|
|
// ones, so a column added in a later release lands at the right-hand end
|
|
// rather than in the middle of somebody's table.
|
|
order, err := normalizeKeys(*in.ServiceColumnOrder, arrangeableServiceColumns, "service column")
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
payload["service_column_order"] = order
|
|
}
|
|
if in.MetricOrder != nil {
|
|
// A partial list again, and here it is the normal case: the client can
|
|
// only arrange the readings the provider actually reported, so one it
|
|
// reports later — an EV range on a car that was parked unplugged — joins
|
|
// at the end rather than displacing the arrangement.
|
|
order, err := normalizeKeys(*in.MetricOrder, arrangeableCarMetrics, "reading")
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
payload["metric_order"] = order
|
|
}
|
|
if len(payload) == 0 {
|
|
writeError(w, http.StatusBadRequest, "no changes provided")
|
|
return
|
|
}
|
|
|
|
var rec carRecord
|
|
if err := s.pb.Update(r.Context(), colCars, r.PathValue("id"), payload, &rec); err != nil {
|
|
writePBError(w, err)
|
|
return
|
|
}
|
|
m := rec.toModel()
|
|
m.Access = level
|
|
writeJSON(w, http.StatusOK, m)
|
|
}
|
|
|
|
func (s *Server) deleteCar(w http.ResponseWriter, r *http.Request) {
|
|
level, _, err := s.carAccessLevel(r.Context(), s.currentUserID(r), r.PathValue("id"))
|
|
if err != nil {
|
|
writePBError(w, err)
|
|
return
|
|
}
|
|
if level != accessOwner {
|
|
writeError(w, http.StatusForbidden, "only the owner can delete this car")
|
|
return
|
|
}
|
|
if err := s.pb.Delete(r.Context(), colCars, r.PathValue("id")); err != nil {
|
|
writePBError(w, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// applyCarDefaults fills the spreadsheet's default maintenance intervals when
|
|
// the client didn't specify them.
|
|
func applyCarDefaults(c *models.Car) {
|
|
if c.ServiceIntervalDays <= 0 {
|
|
c.ServiceIntervalDays = 365
|
|
}
|
|
if c.ServiceIntervalKm <= 0 {
|
|
c.ServiceIntervalKm = 15000
|
|
}
|
|
if c.TechnicalCheckIntervalDays <= 0 {
|
|
c.TechnicalCheckIntervalDays = models.DefaultTechnicalCheckIntervalDays
|
|
}
|
|
}
|