Files
DriverVault/API Server/internal/api/cars.go
T
tajniak81andClaude Opus 5 d4033dbcef A build date you may only half know; one look for an empty cell
Two changes, both about showing what is actually known rather than a tidier
version of it.

The build date asked for a day. A car's build date is often only a year, or a
month and a year - the VIN plate is stamped with a month, the papers carry a
day, a grey import neither - so a field insisting on all three is answered
either with an invented day or with nothing, and both throw away what the owner
did know. The field now picks its own precision: a full date, a month and year,
or a year, each with the control that suits it. A year is typed rather than
picked, because a date picker that makes you walk back to 1998 is worse than
four keystrokes.

Stored as the ISO prefix - "2015", "2015-03", "2015-03-10" - which is ISO 8601
reduced precision, and printed back at exactly that precision. The three shapes
sort and compare as strings in date order, which is why the prefix is stored
rather than a date with a precision field beside it. The formatter takes the
string apart rather than parsing it: "2015-03" read as a UTC instant and printed
in local time hands back February west of Greenwich.

Narrowing the precision keeps what is still true, so a day dropped from
"2015-03-10" leaves "2015-03". Widening clears the field. That is the awkward
half of the control and it is deliberate: there is nothing to widen a year with,
and leaving "2015" behind an empty month box would store a date the screen is
not showing.

The column was free text with no validation at all, which was tolerable while
only a date picker could write it and is not now that three shapes are legal.
normalizeBuildDate parses rather than pattern-matches, so "2015-13" and
"2015-02-31" are refused instead of stored as something no reader can print.

The phone needed changing to avoid destroying this. It parsed buildDate with
DateTime.tryParse, which returns null for "2015" - so a half-known date would
have shown as a dash, and saving the car from the phone would have written ""
back over it. It holds both date fields as the string they arrived as now,
prints them at their own precision, and hands back anything it cannot set. Its
picker still only makes full dates; a precision control there is a separate job.

Separately: an empty cell of the service table had three different looks in one
row. The dash under Notes was body-coloured, as though it were content; the one
under File was 12px, having borrowed the size of the Download button that would
otherwise be there; the one under Changed parts was muted at 14px. They are one
constant now, muted at the row's own size, which is what Next date and Next km
already did for a missing value. The Download link keeps its own styling - it is
an action, not a value.

Verified in a browser: a stored "2015-03" loads as month precision in a month
picker, month to year narrows to "2015", year to day clears, "19x98abc" typed
into the year box sanitises to "1998", saving sends buildDate:"1998" and the
Information tab then reads "1998" - while a full first-registration date beside
it still reads 06-08-2026. All five empty cells across the three columns now
compute to the same size, colour and weight, with the filled ones unchanged. go
vet and go test ./... pass with a new test over the three valid shapes and six
rejects; flutter analyze is clean and 22 tests pass, one new, covering a
half-known date in two date formats and the time zone that could shift it; npm
run build is clean.

Not verified: First registration still demands a full date. The same argument
applies to it and the field is now a reusable component, but it was not asked
for and is one line away. The web formatter's month-name paths - the DMY and MDY
formats, which spell the month out - are covered only by the phone's mirror of
the logic, the web app still having no test runner. A car created through the
Toyota import bypasses the new validation; it only ever produces full dates, so
nothing invalid gets in that way, but it is not guarded. Both apps need
redeploying before any of this is visible.

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

538 lines
18 KiB
Go

package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"sort"
"strings"
"time"
"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
}
buildDate, err := normalizeBuildDate(in.BuildDate)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
in.BuildDate = buildDate
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
}
buildDate, err := normalizeBuildDate(in.BuildDate)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
in.BuildDate = buildDate
// 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.
// normalizeBuildDate checks a build date and hands back the value to store.
//
// It is an ISO 8601 reduced-precision date: a year, a year and a month, or a
// full date. A car's build date is often only half known — the VIN plate
// carries a month, the papers a day, a grey import neither — and a field that
// insisted on all three would be answered either with an invented day or with
// nothing. The three shapes sort and compare as strings in date order, which is
// why the prefix is stored rather than a date plus a precision beside it.
//
// Validated rather than taken as typed, because the column is free text: the
// month has to be a month and the day has to exist, or the stored value is
// something no reader can print.
func normalizeBuildDate(v string) (string, error) {
v = strings.TrimSpace(v)
if v == "" {
return "", nil
}
var layout string
switch len(v) {
case len("2006"):
layout = "2006"
case len("2006-01"):
layout = "2006-01"
case len("2006-01-02"):
layout = "2006-01-02"
default:
return "", fmt.Errorf("build date %q must be a year, a year and month, or a full date", v)
}
// time.Parse rejects month 13 and 31 February for us, so the stored value is
// always a date that happened.
if _, err := time.Parse(layout, v); err != nil {
return "", fmt.Errorf("build date %q must be a year, a year and month, or a full date", v)
}
return v, nil
}
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
}
}