package api import ( "math" "net/http" "time" "reanimator/internal/repository/analytics" ) type AnalyticsDependencies struct { Analytics *analytics.Repository } type analyticsHandlers struct { deps AnalyticsDependencies } func RegisterAnalyticsRoutes(mux *http.ServeMux, deps AnalyticsDependencies) { h := analyticsHandlers{deps: deps} mux.HandleFunc("/analytics/lot-metrics", h.handleLotMetrics) mux.HandleFunc("/analytics/firmware-risk", h.handleFirmwareRisk) mux.HandleFunc("/analytics/spare-forecast", h.handleSpareForecast) } func (h analyticsHandlers) handleLotMetrics(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) return } if h.deps.Analytics == nil { writeError(w, http.StatusInternalServerError, "analytics unavailable") return } start, end, ok := parseWindow(w, r) if !ok { return } items, err := h.deps.Analytics.ListLotMetrics(r.Context(), start, end) if err != nil { writeError(w, http.StatusInternalServerError, "lot metrics failed") return } writeJSON(w, http.StatusOK, map[string]any{ "start": start.Format(time.RFC3339), "end": end.Format(time.RFC3339), "items": items, }) } func (h analyticsHandlers) handleFirmwareRisk(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) return } if h.deps.Analytics == nil { writeError(w, http.StatusInternalServerError, "analytics unavailable") return } start, end, ok := parseWindow(w, r) if !ok { return } items, err := h.deps.Analytics.ListFirmwareRisk(r.Context(), start, end) if err != nil { writeError(w, http.StatusInternalServerError, "firmware risk failed") return } writeJSON(w, http.StatusOK, map[string]any{ "start": start.Format(time.RFC3339), "end": end.Format(time.RFC3339), "items": items, }) } func (h analyticsHandlers) handleSpareForecast(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) return } if h.deps.Analytics == nil { writeError(w, http.StatusInternalServerError, "analytics unavailable") return } start, end, ok := parseWindow(w, r) if !ok { return } horizonDays := 30 if value := r.URL.Query().Get("horizon_days"); value != "" { parsed, err := parseIntParam(value) if err != nil || parsed <= 0 { writeError(w, http.StatusBadRequest, "horizon_days must be a positive integer") return } horizonDays = parsed } multiplier := 1.0 if value := r.URL.Query().Get("multiplier"); value != "" { parsed, err := parseFloatParam(value) if err != nil || parsed < 0 { writeError(w, http.StatusBadRequest, "multiplier must be >= 0") return } multiplier = parsed } if math.IsNaN(multiplier) || math.IsInf(multiplier, 0) { writeError(w, http.StatusBadRequest, "multiplier must be finite") return } items, err := h.deps.Analytics.ForecastSpare(r.Context(), start, end, horizonDays, multiplier) if err != nil { writeError(w, http.StatusInternalServerError, "spare forecast failed") return } writeJSON(w, http.StatusOK, map[string]any{ "start": start.Format(time.RFC3339), "end": end.Format(time.RFC3339), "horizon_days": horizonDays, "multiplier": multiplier, "items": items, }) } func parseWindow(w http.ResponseWriter, r *http.Request) (time.Time, time.Time, bool) { startValue := r.URL.Query().Get("start") endValue := r.URL.Query().Get("end") if startValue == "" || endValue == "" { writeError(w, http.StatusBadRequest, "start and end are required") return time.Time{}, time.Time{}, false } start, err := parseTimeParam(startValue) if err != nil { writeError(w, http.StatusBadRequest, "start must be RFC3339") return time.Time{}, time.Time{}, false } end, err := parseTimeParam(endValue) if err != nil { writeError(w, http.StatusBadRequest, "end must be RFC3339") return time.Time{}, time.Time{}, false } if !start.Before(end) { writeError(w, http.StatusBadRequest, "start must be before end") return time.Time{}, time.Time{}, false } return start, end, true }