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>
179 lines
5.9 KiB
Go
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)
|
|
}
|