package api import ( "encoding/json" "fmt" "net/http" "net/url" "time" "drivervault/apiserver/internal/models" ) // Maintenance log: workshop visits and repairs. // // This is deliberately NOT the service history. A ServiceRecord is the routine, // interval-driven schedule the spreadsheet was built around (oil at 15000 km, // filters once a year) and it drives the next-service-due calculation. A // MaintenanceEntry is unplanned or one-off work done at a garage — a failed // alternator, a clutch, bodywork after a scrape — which has a workshop, an // invoice, and a labour bill, and no bearing on the service interval. // maintenanceTypes are the kinds of visit the log accepts. Anything else is // rejected rather than silently stored, so the UI's filters stay meaningful. var maintenanceTypes = map[string]bool{ "repair": true, "inspection": true, "bodywork": true, "tyres": true, "diagnostics": true, "recall": true, "warranty": true, "other": true, } var maintenanceStatuses = map[string]bool{ "scheduled": true, "in_progress": true, "completed": true, } // listCarMaintenance serves GET /api/cars/{id}/maintenance. func (s *Server) listCarMaintenance(w http.ResponseWriter, r *http.Request) { carID := r.PathValue("id") if !s.requireCarAccess(w, r, carID, accessRead) { return } s.respondMaintenanceList(w, r, carID) } // listMaintenance serves GET /api/maintenance?car={id}. func (s *Server) listMaintenance(w http.ResponseWriter, r *http.Request) { carID := r.URL.Query().Get("car") if !s.requireCarAccess(w, r, carID, accessRead) { return } s.respondMaintenanceList(w, r, carID) } func (s *Server) respondMaintenanceList(w http.ResponseWriter, r *http.Request, carID string) { entries, err := s.fetchMaintenance(r, carID) if err != nil { writePBError(w, err) return } writeJSON(w, http.StatusOK, entries) } // fetchMaintenance loads a car's maintenance entries newest-first with derived // cost and warranty fields filled in. func (s *Server) fetchMaintenance(r *http.Request, carID string) ([]models.MaintenanceEntry, error) { res, err := s.pb.List(r.Context(), colMaintenance, url.Values{ "filter": {fmt.Sprintf("car='%s'", carID)}, "sort": {"-date"}, "perPage": {"500"}, }) if err != nil { return nil, err } var recs []maintenanceRecord if err := json.Unmarshal(res.Items, &recs); err != nil { return nil, err } now := time.Now() out := make([]models.MaintenanceEntry, 0, len(recs)) for _, rec := range recs { m := rec.toModel() m.ComputeMaintenanceDerived(now) out = append(out, m) } return out, nil } func (s *Server) getMaintenance(w http.ResponseWriter, r *http.Request) { var rec maintenanceRecord if err := s.pb.GetOne(r.Context(), colMaintenance, r.PathValue("id"), &rec); err != nil { writePBError(w, err) return } if !s.requireCarAccess(w, r, rec.Car, accessRead) { return } m := rec.toModel() m.ComputeMaintenanceDerived(time.Now()) writeJSON(w, http.StatusOK, m) } func (s *Server) createMaintenance(w http.ResponseWriter, r *http.Request) { var in models.MaintenanceEntry if err := decodeJSON(r, &in); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } applyMaintenanceDefaults(&in) if msg := validateMaintenance(in); msg != "" { writeError(w, http.StatusBadRequest, msg) return } if !s.requireCarAccess(w, r, in.Car, accessWrite) { return } var rec maintenanceRecord if err := s.pb.Create(r.Context(), colMaintenance, maintenancePayload(in), &rec); err != nil { writePBError(w, err) return } // Only completed work proves the car actually reached that odometer; a // scheduled visit carries an estimate of where it will be. if in.Status == "completed" { s.advanceOdometer(r, in.Car, in.Km) } m := rec.toModel() m.ComputeMaintenanceDerived(time.Now()) writeJSON(w, http.StatusCreated, m) } func (s *Server) updateMaintenance(w http.ResponseWriter, r *http.Request) { var in models.MaintenanceEntry if err := decodeJSON(r, &in); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } var existing maintenanceRecord if err := s.pb.GetOne(r.Context(), colMaintenance, r.PathValue("id"), &existing); err != nil { writePBError(w, err) return } if !s.requireCarAccess(w, r, existing.Car, accessWrite) { return } in.Car = existing.Car // the record's car is not reassignable via PATCH applyMaintenanceDefaults(&in) if msg := validateMaintenance(in); msg != "" { writeError(w, http.StatusBadRequest, msg) return } var rec maintenanceRecord if err := s.pb.Update(r.Context(), colMaintenance, r.PathValue("id"), maintenancePayload(in), &rec); err != nil { writePBError(w, err) return } if in.Status == "completed" { s.advanceOdometer(r, rec.Car, in.Km) } m := rec.toModel() m.ComputeMaintenanceDerived(time.Now()) writeJSON(w, http.StatusOK, m) } func (s *Server) deleteMaintenance(w http.ResponseWriter, r *http.Request) { var existing maintenanceRecord if err := s.pb.GetOne(r.Context(), colMaintenance, 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(), colMaintenance, r.PathValue("id")); err != nil { writePBError(w, err) return } w.WriteHeader(http.StatusNoContent) } func applyMaintenanceDefaults(m *models.MaintenanceEntry) { if m.Type == "" { m.Type = "repair" } if m.Status == "" { m.Status = "completed" } } func validateMaintenance(m models.MaintenanceEntry) string { switch { case m.Car == "": return "car is required" case m.Date.IsZero(): return "date is required" case m.Description == "": return "description is required" case !maintenanceTypes[m.Type]: return "type must be one of: repair, inspection, bodywork, tyres, diagnostics, recall, warranty, other" case !maintenanceStatuses[m.Status]: return "status must be one of: scheduled, in_progress, completed" case m.LaborCost < 0 || m.PartsCost < 0: return "costs cannot be negative" } return "" }