package api import ( "encoding/json" "fmt" "net/http" "net/url" "strings" "time" "drivervault/apiserver/internal/models" ) // Technical check history: the mandatory roadworthiness inspections a car has // been through — przegląd techniczny, MOT, TÜV. // // Shaped like the service log next to it, but time-only: an inspection falls due // on a date regardless of the odometer. The next-due date is assessed live on // every read (models.TechnicalCheck.ComputeTechnicalCheckDerived) rather than // stored, for the same reason documents are — a lapsed certificate is a car that // cannot legally be driven, and a stored verdict would quietly go stale. // // The certificate scan is handled by attachments.go, on the same terms as every // other record's attachment. var technicalCheckResults = map[string]bool{"passed": true, "failed": true} // listCarTechnicalChecks serves GET /api/cars/{id}/technical-checks. func (s *Server) listCarTechnicalChecks(w http.ResponseWriter, r *http.Request) { carID := r.PathValue("id") if !s.requireCarAccess(w, r, carID, accessRead) { return } s.respondTechnicalCheckList(w, r, carID) } // listTechnicalChecks serves GET /api/technical-checks?car={id}. func (s *Server) listTechnicalChecks(w http.ResponseWriter, r *http.Request) { carID := r.URL.Query().Get("car") if !s.requireCarAccess(w, r, carID, accessRead) { return } s.respondTechnicalCheckList(w, r, carID) } func (s *Server) respondTechnicalCheckList(w http.ResponseWriter, r *http.Request, carID string) { q := url.Values{} q.Set("sort", "-date") // most-recent check first, like the service log q.Set("perPage", "500") q.Set("filter", fmt.Sprintf("car='%s'", carID)) res, err := s.pb.List(r.Context(), colTechnicalChecks, q) if err != nil { writePBError(w, err) return } var recs []technicalCheckRecord if err := json.Unmarshal(res.Items, &recs); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } cars := newCarCache(s) now := time.Now() out := make([]models.TechnicalCheck, 0, len(recs)) for _, rec := range recs { m := rec.toModel() car, err := cars.get(r.Context(), m.Car) if err != nil { car = nil // fall back to the default interval rather than dropping the row } m.ComputeTechnicalCheckDerived(car, now) out = append(out, m) } writeJSON(w, http.StatusOK, out) } func (s *Server) getTechnicalCheck(w http.ResponseWriter, r *http.Request) { var rec technicalCheckRecord if err := s.pb.GetOne(r.Context(), colTechnicalChecks, r.PathValue("id"), &rec); err != nil { writePBError(w, err) return } m := rec.toModel() if !s.requireCarAccess(w, r, m.Car, accessRead) { return } s.writeTechnicalCheck(w, r, m, http.StatusOK) } func (s *Server) createTechnicalCheck(w http.ResponseWriter, r *http.Request) { var in models.TechnicalCheck if err := decodeJSON(r, &in); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if in.Result == "" { in.Result = "passed" } if msg := validateTechnicalCheck(in); msg != "" { writeError(w, http.StatusBadRequest, msg) return } if !s.requireCarAccess(w, r, in.Car, accessWrite) { return } var rec technicalCheckRecord if err := s.pb.Create(r.Context(), colTechnicalChecks, technicalCheckPayload(in), &rec); err != nil { writePBError(w, err) return } s.writeTechnicalCheck(w, r, rec.toModel(), http.StatusCreated) } func (s *Server) updateTechnicalCheck(w http.ResponseWriter, r *http.Request) { var in models.TechnicalCheck if err := decodeJSON(r, &in); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } // Enforce write access via the record's existing parent car, so the body's // car field cannot be used to escape into another user's car. var existing technicalCheckRecord if err := s.pb.GetOne(r.Context(), colTechnicalChecks, r.PathValue("id"), &existing); err != nil { writePBError(w, err) return } if !s.requireCarAccess(w, r, existing.Car, accessWrite) { return } in.Car = existing.Car // a check's car is not reassignable via PATCH if in.Result == "" { in.Result = "passed" } if msg := validateTechnicalCheck(in); msg != "" { writeError(w, http.StatusBadRequest, msg) return } var rec technicalCheckRecord if err := s.pb.Update(r.Context(), colTechnicalChecks, r.PathValue("id"), technicalCheckPayload(in), &rec); err != nil { writePBError(w, err) return } s.writeTechnicalCheck(w, r, rec.toModel(), http.StatusOK) } func (s *Server) deleteTechnicalCheck(w http.ResponseWriter, r *http.Request) { var existing technicalCheckRecord if err := s.pb.GetOne(r.Context(), colTechnicalChecks, 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(), colTechnicalChecks, r.PathValue("id")); err != nil { writePBError(w, err) return } w.WriteHeader(http.StatusNoContent) } // writeTechnicalCheck derives the next-due date against the parent car and // responds. Shared by the three handlers that return a single check. func (s *Server) writeTechnicalCheck(w http.ResponseWriter, r *http.Request, m models.TechnicalCheck, status int) { car, err := s.carModel(r.Context(), m.Car) if err != nil { car = nil } m.ComputeTechnicalCheckDerived(car, time.Now()) writeJSON(w, status, m) } func validateTechnicalCheck(t models.TechnicalCheck) string { switch { case t.Car == "": return "car is required" case t.Date.IsZero(): return "date is required" case !technicalCheckResults[t.Result]: return "result must be passed or failed" case t.Cost < 0: return "cost cannot be negative" case len(strings.TrimSpace(t.Station)) > 200: return "station name is too long" } if t.ValidUntil != nil && !t.ValidUntil.IsZero() && t.ValidUntil.Before(t.Date) { return "valid-until date cannot be before the check date" } return "" }