package api import ( "context" "encoding/json" "fmt" "net/http" "net/url" "carcontrol/api/internal/models" ) func (s *Server) listServiceRecords(w http.ResponseWriter, r *http.Request) { // Records are always scoped to a single car so access can be enforced; a // cross-car listing would leak other users' data. carID := r.URL.Query().Get("car") if !s.requireCarAccess(w, r, carID, accessRead) { return } q := url.Values{} q.Set("sort", "-date") // most-recent service first q.Set("perPage", "500") q.Set("filter", fmt.Sprintf("car='%s'", carID)) s.respondServiceList(w, r, q) } // listCarServiceRecords serves GET /api/cars/{id}/service-records. func (s *Server) listCarServiceRecords(w http.ResponseWriter, r *http.Request) { carID := r.PathValue("id") if !s.requireCarAccess(w, r, carID, accessRead) { return } q := url.Values{} q.Set("sort", "-date") q.Set("perPage", "500") q.Set("filter", fmt.Sprintf("car='%s'", carID)) s.respondServiceList(w, r, q) } func (s *Server) respondServiceList(w http.ResponseWriter, r *http.Request, q url.Values) { res, err := s.pb.List(r.Context(), colServices, q) if err != nil { writePBError(w, err) return } var recs []serviceRecord if err := json.Unmarshal(res.Items, &recs); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } cars := newCarCache(s) out := make([]models.ServiceRecord, 0, len(recs)) for _, rec := range recs { m := rec.toModel() if car, err := cars.get(r.Context(), m.Car); err == nil { m.ComputeDerived(car) } out = append(out, m) } writeJSON(w, http.StatusOK, out) } func (s *Server) getServiceRecord(w http.ResponseWriter, r *http.Request) { var rec serviceRecord if err := s.pb.GetOne(r.Context(), colServices, r.PathValue("id"), &rec); err != nil { writePBError(w, err) return } m := rec.toModel() if !s.requireCarAccess(w, r, m.Car, accessRead) { return } if car, err := s.carModel(r.Context(), m.Car); err == nil { m.ComputeDerived(car) } writeJSON(w, http.StatusOK, m) } func (s *Server) createServiceRecord(w http.ResponseWriter, r *http.Request) { var in models.ServiceRecord if err := decodeJSON(r, &in); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if in.Car == "" { writeError(w, http.StatusBadRequest, "car is required") return } if in.Date.IsZero() { writeError(w, http.StatusBadRequest, "date is required") return } if !s.requireCarAccess(w, r, in.Car, accessWrite) { return } var rec serviceRecord if err := s.pb.Create(r.Context(), colServices, servicePayload(in), &rec); err != nil { writePBError(w, err) return } m := rec.toModel() if car, err := s.carModel(r.Context(), m.Car); err == nil { m.ComputeDerived(car) } writeJSON(w, http.StatusCreated, m) } func (s *Server) updateServiceRecord(w http.ResponseWriter, r *http.Request) { var in models.ServiceRecord if err := decodeJSON(r, &in); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } // Enforce write access via the record's existing parent car (the body's // car field, if any, can't be used to escape into another user's car). var existing serviceRecord if err := s.pb.GetOne(r.Context(), colServices, r.PathValue("id"), &existing); err != nil { writePBError(w, err) return } if !s.requireCarAccess(w, r, existing.Car, accessWrite) { return } var rec serviceRecord if err := s.pb.Update(r.Context(), colServices, r.PathValue("id"), servicePayload(in), &rec); err != nil { writePBError(w, err) return } m := rec.toModel() if car, err := s.carModel(r.Context(), m.Car); err == nil { m.ComputeDerived(car) } writeJSON(w, http.StatusOK, m) } func (s *Server) deleteServiceRecord(w http.ResponseWriter, r *http.Request) { var existing serviceRecord if err := s.pb.GetOne(r.Context(), colServices, 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(), colServices, r.PathValue("id")); err != nil { writePBError(w, err) return } w.WriteHeader(http.StatusNoContent) } // carModel fetches a single car as a domain model. func (s *Server) carModel(ctx context.Context, id string) (*models.Car, error) { if id == "" { return nil, fmt.Errorf("empty car id") } var rec carRecord if err := s.pb.GetOne(ctx, colCars, id, &rec); err != nil { return nil, err } m := rec.toModel() return &m, nil } // carCache memoizes car lookups within a single list request to avoid N+1 // fetches when many service records share the same car. type carCache struct { s *Server cache map[string]*models.Car } func newCarCache(s *Server) *carCache { return &carCache{s: s, cache: map[string]*models.Car{}} } func (c *carCache) get(ctx context.Context, id string) (*models.Car, error) { if car, ok := c.cache[id]; ok { return car, nil } car, err := c.s.carModel(ctx, id) if err != nil { return nil, err } c.cache[id] = car return car, nil }