package stats
import (
"context"
"log/slog"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"go.bigb.es/auxilia/culpa"
"go.bigb.es/auxilia/scribe"
"sourcecraft.dev/bigbes/lethe/internal/domain/session"
"sourcecraft.dev/bigbes/lethe/internal/pkg/apierror"
"sourcecraft.dev/bigbes/lethe/internal/pkg/httputil"
"sourcecraft.dev/bigbes/lethe/internal/server/auth"
)
// allOwnersSentinel is the ?owner=* value that an admin uses to read across
// all owners. Identical to the session and project handler sentinels.
const allOwnersSentinel = "*"
// validRanges is the set of accepted ?range= values.
var validRanges = map[string]int{
"7d": 7,
"30d": 30,
"90d": 90,
}
// Handler is the steward-managed HTTP boundary for the stats aggregation API.
type Handler struct {
Repo *Repository `inject:""`
}
// Init satisfies the steward Initer contract.
func (h *Handler) Init(_ context.Context) error { return nil }
// Mount registers the single read route under r. Server.Init mounts this
// inside the /api/v1 group, so the effective path is /api/v1/stats.
func (h *Handler) Mount(r chi.Router) {
r.Get("/stats", h.List)
}
// List handles GET /stats. Reads ?range=7d|30d|90d|all (defaults to 30d),
// resolves the owner scope, and returns a Stats bundle.
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
scope, err := h.resolveScope(r)
if err != nil {
apierror.Render(w, r, err)
return
}
now := time.Now().Unix()
f := Filter{Owner: scope, Now: now}
rangeParam := r.URL.Query().Get("range")
switch {
case rangeParam == "" || rangeParam == "30d":
// default: 30 days
since := now - 30*86400
f.RangeSince = &since
case rangeParam == "all":
f.RangeSince = nil
default:
days, ok := validRanges[rangeParam]
if !ok {
apierror.Render(w, r, culpa.WithCode(
culpa.WithPublic(
culpa.Errorf("unrecognized range %q", rangeParam),
`?range= must be one of: 7d, 30d, 90d, all`,
),
"INVALID",
))
return
}
since := now - int64(days)*86400
f.RangeSince = &since
}
result, err := h.Repo.Stats(r.Context(), f)
if err != nil {
apierror.Render(w, r, err)
return
}
if writeErr := httputil.WriteJSON(w, http.StatusOK, result); writeErr != nil {
slog.Default().ErrorContext(r.Context(), "write stats response", scribe.Err(writeErr))
}
}
// resolveScope reads the authenticated identity off the context and the
// optional ?owner= query parameter, then returns the appropriate
// session.OwnerScope. Non-admin requests with ?owner= set are 403.
func (h *Handler) resolveScope(r *http.Request) (session.OwnerScope, error) {
id := auth.MustIdentity(r.Context())
param := r.URL.Query().Get("owner")
if param == "" {
return session.OwnerScope{User: id.User}, nil
}
if !id.IsAdmin {
return session.OwnerScope{}, culpa.WithCode(
culpa.WithPublic(culpa.New("?owner= is admin-only"), "?owner= is admin-only"),
"FORBIDDEN",
)
}
if param == allOwnersSentinel {
return session.OwnerScope{User: id.User, AllOwners: true}, nil
}
owner := strings.ToLower(param)
return session.OwnerScope{User: id.User, SpecificOwner: &owner}, nil
}