Files
tajniak81andClaude Opus 5 423bc2ab16 The charger list sorted by a field the collection never had
Opening Home chargers answered {"status":400,"message":"Something went wrong
while processing your request."} — PocketBase's generic refusal, here for an
unknown sort field. home_chargers declares its own fields and nothing else:
PocketBase adds no created field to a collection defined through the API, which
is exactly why control_audit and organizations declare theirs. The list handler
sorted by created anyway. It was the only handler in the server that sorts by
created — every other one sorts by name, km or date, fields their collections
actually declare — so the gap had never had a chance to show.

The field is now declared, and reconcile adds it to the collection already
standing on the next boot, since home_chargers is in reconcileOrder. Import order
is the only order a charger has: it carries no date of its own, and a wallbox
bolted to a wall does not accumulate events the way a car does.

The list also stops depending on that. A rejected sort now falls back to the
unsorted query rather than failing the request: the order is a nicety, the list
is not, and an owner reading a database error about a field they cannot see is
the worst of both. It also makes the deploy order stop mattering — the page works
before the bootstrap has run, and the sorted query wins once it has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 21:34:48 +02:00

179 lines
5.9 KiB
Go

package api
// A home charger is the wallbox a user owns, stored the way a car is: a record
// of their own, listed on the Charging page, kept after the account it came from
// is disconnected. It belongs to the person rather than to a car — it charges
// whichever car is plugged into it, and it outlives any of them — so there is no
// car relation here and no sharing: everyone sees their own chargers only.
//
// GET /api/home-chargers — the caller's chargers
// PATCH /api/home-chargers/{id} — rename one
// DELETE /api/home-chargers/{id} — forget one
//
// Creating one is the business of chargerproviders.go: a charger is imported
// from a service the user has connected, the same move the garage makes for a
// car. Nothing here writes the provider link, so a rename cannot silently break
// it.
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"drivervault/apiserver/internal/models"
)
// homeChargerRecord is the PocketBase-facing shape of a home charger.
type homeChargerRecord struct {
ID string `json:"id"`
Name string `json:"name"`
Serial string `json:"serial"`
Vendor string `json:"vendor"`
Model string `json:"model"`
SiteName string `json:"site_name"`
PowerKw float64 `json:"power_kw"`
Connector string `json:"connector"`
Provider string `json:"provider"`
ProviderChargerID string `json:"provider_charger_id"`
Owner string `json:"owner"`
Created string `json:"created"`
}
func (rec homeChargerRecord) toModel() models.HomeCharger {
return models.HomeCharger{
ID: rec.ID,
Name: rec.Name,
Serial: rec.Serial,
Vendor: rec.Vendor,
Model: rec.Model,
SiteName: rec.SiteName,
PowerKw: rec.PowerKw,
Connector: rec.Connector,
Provider: rec.Provider,
ProviderChargerID: rec.ProviderChargerID,
Created: rec.Created,
}
}
// homeChargerPayload is the write shape. The provider link is not part of it:
// only the import endpoint sets that, and it adds those two fields itself.
func homeChargerPayload(c models.HomeCharger) map[string]any {
return map[string]any{
"name": c.Name,
"serial": c.Serial,
"vendor": c.Vendor,
"model": c.Model,
"site_name": c.SiteName,
"power_kw": c.PowerKw,
"connector": c.Connector,
}
}
// listHomeChargers returns the caller's own chargers, newest first — the order
// they were imported in, which is the order they were installed in often enough.
func (s *Server) listHomeChargers(w http.ResponseWriter, r *http.Request) {
me := s.currentUserID(r)
if me == "" {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
query := func(sort string) url.Values {
q := url.Values{
"filter": {fmt.Sprintf("owner='%s'", me)},
"perPage": {"200"},
}
if sort != "" {
q.Set("sort", sort)
}
return q
}
res, err := s.pb.List(r.Context(), colHomeChargers, query("created"))
if err != nil {
// A collection created before `created` was declared does not have the
// field, and PocketBase rejects the whole query over an unknown sort. The
// order is a nicety; the list is not, so ask again without it rather than
// show the owner an error about a field they cannot see. The bootstrap
// adds the field on the next boot, and the sorted query then wins.
res, err = s.pb.List(r.Context(), colHomeChargers, query(""))
}
if err != nil {
writePBError(w, err)
return
}
var recs []homeChargerRecord
if err := json.Unmarshal(res.Items, &recs); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
out := make([]models.HomeCharger, 0, len(recs))
for _, rec := range recs {
out = append(out, rec.toModel())
}
writeJSON(w, http.StatusOK, map[string]any{"chargers": out})
}
// ownedHomeCharger fetches one charger and checks it is the caller's. It writes
// the error response itself and returns ok=false when it is not.
func (s *Server) ownedHomeCharger(w http.ResponseWriter, r *http.Request) (homeChargerRecord, bool) {
var rec homeChargerRecord
id := strings.TrimSpace(r.PathValue("id"))
if id == "" {
writeError(w, http.StatusBadRequest, "charger id is required")
return rec, false
}
if err := s.pb.GetOne(r.Context(), colHomeChargers, id, &rec); err != nil {
writePBError(w, err)
return rec, false
}
// Someone else's charger is not found rather than forbidden: whether a record
// exists is not this caller's business either.
if rec.Owner != s.currentUserID(r) {
writeError(w, http.StatusNotFound, "charger not found")
return rec, false
}
return rec, true
}
// updateHomeCharger renames a charger. Only the name is editable — everything
// else describes the hardware and comes from the service it was imported from.
func (s *Server) updateHomeCharger(w http.ResponseWriter, r *http.Request) {
rec, ok := s.ownedHomeCharger(w, r)
if !ok {
return
}
var body struct {
Name string `json:"name"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
name := strings.TrimSpace(body.Name)
if name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
var updated homeChargerRecord
if err := s.pb.Update(r.Context(), colHomeChargers, rec.ID, map[string]any{"name": name}, &updated); err != nil {
writePBError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"charger": updated.toModel()})
}
// deleteHomeCharger forgets a charger. The charger itself is untouched — this is
// a record about it, and importing it again brings it straight back.
func (s *Server) deleteHomeCharger(w http.ResponseWriter, r *http.Request) {
rec, ok := s.ownedHomeCharger(w, r)
if !ok {
return
}
if err := s.pb.Delete(r.Context(), colHomeChargers, rec.ID); err != nil {
writePBError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}