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 }