package api import ( "encoding/json" "fmt" "net/http" "net/url" "sort" "drivervault/apiserver/internal/models" ) // Charging tracking: a log of charges per car, plus the efficiency derived from // it. The EV counterpart of fuel.go, and deliberately the same shape — a car // that runs on electrons still has a cost per km, and the two tabs read alike. // // Nothing about consumption is stored — it is recomputed from the whole history // on every read (models.ComputeChargingDerived), so correcting a session three // months back fixes every window it touches with no rows to migrate. // fetchChargingSessions loads a car's charges oldest-first and fills in the // derived efficiency fields. Ordering is by odometer rather than date because // the windows are spans of distance, and a session logged with the wrong date // would otherwise scramble the chain. func (s *Server) fetchChargingSessions(r *http.Request, carID string) ([]models.ChargingSession, error) { res, err := s.pb.List(r.Context(), colCharging, url.Values{ "filter": {fmt.Sprintf("car='%s'", carID)}, "sort": {"km"}, "perPage": {"1000"}, }) if err != nil { return nil, err } var recs []chargingRecord if err := json.Unmarshal(res.Items, &recs); err != nil { return nil, err } out := make([]models.ChargingSession, 0, len(recs)) for _, rec := range recs { out = append(out, rec.toModel()) } // PocketBase sorts numerically here, but re-sorting locally keeps the // invariant ComputeChargingDerived depends on explicit and cheap. sort.SliceStable(out, func(i, j int) bool { return out[i].Km < out[j].Km }) models.ComputeChargingDerived(out) return out, nil } // listCarChargingSessions serves GET /api/cars/{id}/charging-sessions, newest first. func (s *Server) listCarChargingSessions(w http.ResponseWriter, r *http.Request) { carID := r.PathValue("id") if !s.requireCarAccess(w, r, carID, accessRead) { return } entries, err := s.fetchChargingSessions(r, carID) if err != nil { writePBError(w, err) return } writeJSON(w, http.StatusOK, reverseCharging(entries)) } // listChargingSessions serves GET /api/charging-sessions?car={id}. func (s *Server) listChargingSessions(w http.ResponseWriter, r *http.Request) { carID := r.URL.Query().Get("car") if !s.requireCarAccess(w, r, carID, accessRead) { return } entries, err := s.fetchChargingSessions(r, carID) if err != nil { writePBError(w, err) return } writeJSON(w, http.StatusOK, reverseCharging(entries)) } // listCarChargingStats serves GET /api/cars/{id}/charging-stats. func (s *Server) listCarChargingStats(w http.ResponseWriter, r *http.Request) { carID := r.PathValue("id") if !s.requireCarAccess(w, r, carID, accessRead) { return } entries, err := s.fetchChargingSessions(r, carID) if err != nil { writePBError(w, err) return } writeJSON(w, http.StatusOK, models.ComputeChargingStats(entries)) } // reverseCharging flips the oldest-first working order into the newest-first // order clients display. func reverseCharging(in []models.ChargingSession) []models.ChargingSession { out := make([]models.ChargingSession, len(in)) for i, e := range in { out[len(in)-1-i] = e } return out } // withChargingDerived recomputes the whole history and returns the one session // the caller just wrote, so a create/update response carries the same derived // figures the list would show. func (s *Server) withChargingDerived(r *http.Request, carID, id string) (models.ChargingSession, error) { entries, err := s.fetchChargingSessions(r, carID) if err != nil { return models.ChargingSession{}, err } for _, e := range entries { if e.ID == id { return e, nil } } return models.ChargingSession{}, fmt.Errorf("charging session %s not found after write", id) } func (s *Server) getChargingSession(w http.ResponseWriter, r *http.Request) { var rec chargingRecord if err := s.pb.GetOne(r.Context(), colCharging, r.PathValue("id"), &rec); err != nil { writePBError(w, err) return } if !s.requireCarAccess(w, r, rec.Car, accessRead) { return } entry, err := s.withChargingDerived(r, rec.Car, rec.ID) if err != nil { writePBError(w, err) return } writeJSON(w, http.StatusOK, entry) } func (s *Server) createChargingSession(w http.ResponseWriter, r *http.Request) { var in models.ChargingSession if err := decodeJSON(r, &in); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if err := validateChargingSession(in); err != "" { writeError(w, http.StatusBadRequest, err) return } if !s.requireCarAccess(w, r, in.Car, accessWrite) { return } var rec chargingRecord if err := s.pb.Create(r.Context(), colCharging, chargingPayload(in), &rec); err != nil { writePBError(w, err) return } // A charge is also the freshest odometer reading there is, exactly as a // refill is; keeping the car in step means the km-based service and reminder // status stay honest without the user retyping the number on the car itself. s.advanceOdometer(r, in.Car, in.Km) entry, err := s.withChargingDerived(r, rec.Car, rec.ID) if err != nil { writePBError(w, err) return } writeJSON(w, http.StatusCreated, entry) } func (s *Server) updateChargingSession(w http.ResponseWriter, r *http.Request) { var in models.ChargingSession if err := decodeJSON(r, &in); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } var existing chargingRecord if err := s.pb.GetOne(r.Context(), colCharging, r.PathValue("id"), &existing); err != nil { writePBError(w, err) return } if !s.requireCarAccess(w, r, existing.Car, accessWrite) { return } // The car is fixed by the record being edited; a body claiming another car // must not move the session across the access boundary just checked. in.Car = existing.Car if err := validateChargingSession(in); err != "" { writeError(w, http.StatusBadRequest, err) return } var rec chargingRecord if err := s.pb.Update(r.Context(), colCharging, r.PathValue("id"), chargingPayload(in), &rec); err != nil { writePBError(w, err) return } s.advanceOdometer(r, rec.Car, in.Km) entry, err := s.withChargingDerived(r, rec.Car, rec.ID) if err != nil { writePBError(w, err) return } writeJSON(w, http.StatusOK, entry) } func (s *Server) deleteChargingSession(w http.ResponseWriter, r *http.Request) { var existing chargingRecord if err := s.pb.GetOne(r.Context(), colCharging, r.PathValue("id"), &existing); err != nil { writePBError(w, err) return } if !s.requireCarAccess(w, r, existing.Car, accessWrite) { return } if err := s.pb.Delete(r.Context(), colCharging, r.PathValue("id")); err != nil { writePBError(w, err) return } w.WriteHeader(http.StatusNoContent) } // validateChargingSession returns a human-readable reason the session is // unusable, or "" when it is fine. func validateChargingSession(c models.ChargingSession) string { switch { case c.Car == "": return "car is required" case c.Date.IsZero(): return "date is required" case c.Km < 0: return "odometer (km) cannot be negative" case c.Kwh <= 0: return "kwh must be greater than zero" case c.Cost < 0: return "cost cannot be negative" } return "" }