Oil & Oil filter, Engine air filter and Cabin air filter had a column of Yes/No
each in the Service history table, 375px of the 1022px table between them for
three bits of information. The form has always kept them together in one Changed
parts section, which is the honest shape: they are one answer to one question
about a service, not three unrelated readings. The table said otherwise, and the
list is going to grow - every part added would have taken another column and
pushed the table into a sideways scroll.
They are one column now, 234px with the widest summary on screen, and its width
no longer depends on how many parts exist. The cell names what was changed
rather than counting it, because a history is read down the page and "2" tells
you nothing about which two; past two names it becomes the first part and a
tally, which is what keeps one line one line as the list grows. Nothing changed
reads as an em dash.
The detail is a dropdown, not a dialog. This is read-only detail about one row
of a table you are reading down: a modal would black out the rows being compared
against and charge an open-and-close for each one. It is pinned under the button
it was opened from, closes on an outside click, Escape or a scroll - it is fixed
to a point on the screen, so a table that moves underneath would leave it
pointing at the wrong row - and there is one panel rather than one per row. It
lists every part with a Yes or a No, the unchanged ones included, so the em-dash
row still answers the question instead of being a dead cell.
One list in lib/serviceParts.js now drives the form's checkboxes, the cell's
summary and the panel. That is the point of the change as much as the width is:
adding a part was three edits that had to agree, and is now one entry plus its
boolean on the API's service_records collection. The form builds its state and
its payload from the list rather than naming the three fields twice - the save
payload is unchanged in shape, which was checked against the wire rather than by
reading it.
This walks back part of the previous commit, which had just made all three
hideable separately: the server's column set drops oil/engineFilter/cabinFilter
for a single "parts" key, and a test now asserts those three are not columns of
their own, so the table cannot drift back. A car with ["oil"] stored as hidden
would quietly get the combined column - nothing has that stored, the deployed
stack predating the feature, and stale keys are dropped on read rather than
erroring.
Verified in a browser against a stub API: all four summary cases (one part
named, two named, three as "Oil & Oil filter +2", none as an em dash); the panel
opens anchored under its button with the right Yes/No for the row, stays inside
the window, and closes on outside click, Escape, scroll and a second click,
switching rows without leaving a second panel behind; the picker offers "Changed
parts" as one entry and hiding it sends {"hiddenServiceColumns":["parts"]};
dragging sends "parts" in the order with the hidden column holding its slot; the
Edit dialog renders from the shared list and its PATCH still carries all three
booleans with the unticked one false. go vet, go test ./... and npm run build
are clean.
Not verified: no automated test covers any of it - the web app still has no test
runner, so the cases above were driven by hand. The drag and the panel were
exercised through dispatched events rather than a pointer, the browser pane not
compositing, so the native drag image and the panel's behaviour under a real
click-and-hold are unchecked. The dropdown overlaps the rows beneath it, which
is what a dropdown does but was not weighed against a taller table. The deployed
Web App still shows three columns until it is redeployed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
489 lines
16 KiB
Go
489 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. "parts" is the one column covering every part a service
|
|
// can have changed — they are a growing list, and one column per part would
|
|
// widen the table indefinitely — so the set does not grow when a part is added.
|
|
var hideableServiceColumns = map[string]bool{
|
|
"km": true, "nextDate": true, "nextKm": true, "parts": 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
|
|
}
|
|
}
|