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 { VIN string Protocol string IncludeTotal bool DateFrom string DateTo string Limit int Offset int } type MetricRow struct { VIN string `json:"vin"` StatDate string `json:"stat_date"` Protocol string `json:"protocol"` DailyMileageKM float64 `json:"daily_mileage_km"` FirstTotalMileageKM *float64 `json:"first_total_mileage_km,omitempty"` LatestTotalMileageKM *float64 `json:"latest_total_mileage_km,omitempty"` SampleCount int64 `json:"sample_count"` 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() out := make([]MetricRow, 0) for rows.Next() { var row MetricRow var statDate scanDate var updatedAt scanDateTime var first sql.NullFloat64 var latest sql.NullFloat64 if err := rows.Scan( &row.VIN, &statDate, &row.Protocol, &row.DailyMileageKM, &first, &latest, &row.SampleCount, &updatedAt, ); err != nil { return nil, err } row.StatDate = statDate.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 (r *MetricRepository) Count(ctx context.Context, query MetricQuery) (int64, error) { query = normalizeMetricQuery(query) sqlText, args := buildMetricCountSQL(query) rows, err := r.db.QueryContext(ctx, sqlText, args...) if err != nil { return 0, err } defer rows.Close() var total int64 if rows.Next() { if err := rows.Scan(&total); err != nil { return 0, err } } return total, rows.Err() } func normalizeMetricQuery(query MetricQuery) MetricQuery { query.VIN = strings.TrimSpace(query.VIN) query.Protocol = strings.ToUpper(strings.TrimSpace(query.Protocol)) 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) { where, args := buildMetricWhere(query) sqlText := `SELECT vin, stat_date, protocol, daily_mileage_km, first_total_mileage_km, latest_total_mileage_km, sample_count, updated_at FROM vehicle_daily_mileage` if len(where) > 0 { sqlText += " WHERE " + strings.Join(where, " AND ") } sqlText += " ORDER BY stat_date DESC, vin ASC, protocol ASC LIMIT ? OFFSET ?" args = append(args, query.Limit, query.Offset) return sqlText, args } func buildMetricCountSQL(query MetricQuery) (string, []any) { where, args := buildMetricWhere(query) sqlText := `SELECT COUNT(*) FROM vehicle_daily_mileage` if len(where) > 0 { sqlText += " WHERE " + strings.Join(where, " AND ") } return sqlText, args } func buildMetricWhere(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.Protocol != "" { add("protocol = ?", query.Protocol) } if query.DateFrom != "" { add("stat_date >= ?", query.DateFrom) } if query.DateTo != "" { add("stat_date <= ?", query.DateTo) } return where, 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 } var total int64 if query.IncludeTotal { total, err = h.repository.Count(r.Context(), query) if err != nil { writeMetricError(w, http.StatusInternalServerError, err.Error()) return } } rows, err := h.repository.Query(r.Context(), query) if err != nil { writeMetricError(w, http.StatusInternalServerError, err.Error()) return } if !query.IncludeTotal { total = int64(len(rows)) } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "items": rows, "total": total, "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{ VIN: values.Get("vin"), Protocol: values.Get("protocol"), IncludeTotal: strings.EqualFold(strings.TrimSpace(values.Get("includeTotal")), "true"), 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)) } }