311 lines
7.5 KiB
Go
311 lines
7.5 KiB
Go
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))
|
|
}
|
|
}
|