package stats import ( "context" "database/sql" "encoding/json" "errors" "fmt" "net/http" "strconv" "strings" "time" ) type Queryer interface { QueryContext(context.Context, string, ...any) (*sql.Rows, error) } type MetricQuery struct { VehicleKey string VIN string Protocol string MetricKey string DateFrom string DateTo string Limit int Offset int } type MetricRow struct { VehicleKey string `json:"vehicle_key"` VIN string `json:"vin"` StatDate string `json:"stat_date"` Protocol string `json:"protocol"` MetricKey string `json:"metric_key"` MetricValue float64 `json:"metric_value"` MetricUnit string `json:"metric_unit"` FirstTotalMileageKM *float64 `json:"first_total_mileage_km,omitempty"` LatestTotalMileageKM *float64 `json:"latest_total_mileage_km,omitempty"` SampleCount int64 `json:"sample_count"` CalculationMethod string `json:"calculation_method"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } type MetricRepository struct { db Queryer } func NewMetricRepository(db Queryer) *MetricRepository { if db == nil { panic("metric query db must not be nil") } return &MetricRepository{db: db} } func (r *MetricRepository) Query(ctx context.Context, query MetricQuery) ([]MetricRow, error) { query = normalizeMetricQuery(query) sqlText, args := buildMetricSQL(query) rows, err := r.db.QueryContext(ctx, sqlText, args...) if err != nil { return nil, err } defer rows.Close() var out []MetricRow for rows.Next() { var row MetricRow var statDate scanDate var createdAt scanDateTime var updatedAt scanDateTime var first sql.NullFloat64 var latest sql.NullFloat64 if err := rows.Scan( &row.VehicleKey, &row.VIN, &statDate, &row.Protocol, &row.MetricKey, &row.MetricValue, &row.MetricUnit, &first, &latest, &row.SampleCount, &row.CalculationMethod, &createdAt, &updatedAt, ); err != nil { return nil, err } row.StatDate = statDate.String row.CreatedAt = createdAt.String row.UpdatedAt = updatedAt.String if first.Valid { row.FirstTotalMileageKM = &first.Float64 } if latest.Valid { row.LatestTotalMileageKM = &latest.Float64 } out = append(out, row) } return out, rows.Err() } func normalizeMetricQuery(query MetricQuery) MetricQuery { query.VehicleKey = strings.TrimSpace(query.VehicleKey) query.VIN = strings.TrimSpace(query.VIN) query.Protocol = strings.ToUpper(strings.TrimSpace(query.Protocol)) query.MetricKey = strings.TrimSpace(query.MetricKey) query.DateFrom = strings.TrimSpace(query.DateFrom) query.DateTo = strings.TrimSpace(query.DateTo) if query.Limit <= 0 { query.Limit = 50 } return query } func buildMetricSQL(query MetricQuery) (string, []any) { var where []string var args []any add := func(clause string, value any) { where = append(where, clause) args = append(args, value) } if query.VIN != "" { add("vin = ?", query.VIN) } if query.VehicleKey != "" { add("vehicle_key = ?", query.VehicleKey) } if query.Protocol != "" { add("protocol = ?", query.Protocol) } if query.MetricKey != "" { add("metric_key = ?", query.MetricKey) } if query.DateFrom != "" { add("stat_date >= ?", query.DateFrom) } if query.DateTo != "" { add("stat_date <= ?", query.DateTo) } sqlText := `SELECT vehicle_key, vin, stat_date, protocol, metric_key, metric_value, metric_unit, first_total_mileage_km, latest_total_mileage_km, sample_count, calculation_method, created_at, updated_at FROM vehicle_daily_metric` if len(where) > 0 { sqlText += " WHERE " + strings.Join(where, " AND ") } sqlText += " ORDER BY stat_date DESC, vehicle_key ASC, protocol ASC, metric_key ASC LIMIT ? OFFSET ?" args = append(args, query.Limit, query.Offset) return sqlText, args } type MetricHandler struct { repository *MetricRepository } func NewMetricHandler(repository *MetricRepository) *MetricHandler { if repository == nil { panic("metric repository must not be nil") } return &MetricHandler{repository: repository} } func (h *MetricHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeMetricError(w, http.StatusMethodNotAllowed, "method not allowed") return } if strings.Trim(r.URL.Path, "/") != "api/stats/daily-metrics" { writeMetricError(w, http.StatusNotFound, "route not found") return } query, err := parseMetricQuery(r) if err != nil { writeMetricError(w, http.StatusBadRequest, err.Error()) return } rows, err := h.repository.Query(r.Context(), query) if err != nil { writeMetricError(w, http.StatusInternalServerError, err.Error()) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "items": rows, "total": len(rows), "limit": query.Limit, "offset": query.Offset, }) } func parseMetricQuery(r *http.Request) (MetricQuery, error) { values := r.URL.Query() limit, err := parseBoundedInt(values.Get("limit"), 50, 1, 1000, "limit") if err != nil { return MetricQuery{}, err } offset, err := parseBoundedInt(values.Get("offset"), 0, 0, 1_000_000, "offset") if err != nil { return MetricQuery{}, err } query := MetricQuery{ VehicleKey: values.Get("vehicleKey"), VIN: values.Get("vin"), Protocol: values.Get("protocol"), MetricKey: values.Get("metricKey"), DateFrom: values.Get("dateFrom"), DateTo: values.Get("dateTo"), Limit: limit, Offset: offset, } if !validDate(query.DateFrom) || !validDate(query.DateTo) { return MetricQuery{}, errors.New("dateFrom/dateTo must use YYYY-MM-DD") } return normalizeMetricQuery(query), nil } func parseBoundedInt(raw string, fallback int, min int, max int, name string) (int, error) { raw = strings.TrimSpace(raw) if raw == "" { return fallback, nil } value, err := strconv.Atoi(raw) if err != nil || value < min || value > max { return 0, fmt.Errorf("%s must be between %d and %d", name, min, max) } return value, nil } func validDate(value string) bool { value = strings.TrimSpace(value) if value == "" { return true } if len(value) != len("2006-01-02") { return false } for i, r := range value { switch i { case 4, 7: if r != '-' { return false } default: if r < '0' || r > '9' { return false } } } return true } func writeMetricError(w http.ResponseWriter, status int, message string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(map[string]any{"error": message}) } type scanDate struct { String string } func (s *scanDate) Scan(value any) error { s.String = formatSQLTime(value, "2006-01-02") return nil } type scanDateTime struct { String string } func (s *scanDateTime) Scan(value any) error { s.String = formatSQLTime(value, "2006-01-02 15:04:05") return nil } func formatSQLTime(value any, layout string) string { switch typed := value.(type) { case nil: return "" case time.Time: return typed.Format(layout) case []byte: return strings.TrimSpace(string(typed)) case string: return strings.TrimSpace(typed) default: return strings.TrimSpace(fmt.Sprint(typed)) } }