package handlers import ( "bookhoard/internal/database" "bookhoard/internal/services" "errors" "net/http" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v5" ) // StatsHandler serves the reading-statistics API (/api/stats/*) backed // by the StatisticsService. The server owns every conversion: clients // receive user-timezone-rendered strings plus raw minute/page counts. type StatsHandler struct { statsService *services.StatisticsService } func NewStatsHandler(statsService *services.StatisticsService) *StatsHandler { return &StatsHandler{statsService: statsService} } func mustUser(c *echo.Context) (database.Users, bool) { user, ok := c.Get("user").(database.Users) return user, ok } // Summary handles GET /api/stats/summary?range=7|30|90|year — the /stats // page's complete payload: totals, daily arrays, streaks, most-active // day, top books, per-library breakdown. func (h *StatsHandler) Summary(c *echo.Context) error { user, ok := mustUser(c) if !ok { return echo.NewHTTPError(http.StatusUnauthorized, "invalid user") } summary, err := h.statsService.UserSummary(c.Request().Context(), pgtype.UUID{Bytes: user.ID.Bytes, Valid: true}, user.Timezone.String, c.QueryParam("range"), ) if err != nil { if err.Error()[:7] == "invalid" { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } return c.JSON(http.StatusOK, summary) } // ThisWeek handles GET /api/stats/this-week — the dashboard widget's // data: rolling 7-day totals vs the previous seven days, plus streak. // An optional ?library_id scopes the week to one library (the // dashboard's library switcher drives it). func (h *StatsHandler) ThisWeek(c *echo.Context) error { user, ok := mustUser(c) if !ok { return echo.NewHTTPError(http.StatusUnauthorized, "invalid user") } var libraryID pgtype.UUID if raw := c.QueryParam("library_id"); raw != "" { parsed, err := uuid.Parse(raw) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, "invalid library id") } libraryID = pgtype.UUID{Bytes: parsed, Valid: true} } week, err := h.statsService.ThisWeek(c.Request().Context(), pgtype.UUID{Bytes: user.ID.Bytes, Valid: true}, user.Timezone.String, libraryID, ) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } return c.JSON(http.StatusOK, week) } // LibraryUsage handles GET /api/stats/library-usage?library_id= // — the /stats Library tab: media_items INVENTORY aggregates (items, // authors, narrators, size, audio hours, pages, top genres/authors, // longest/largest items) for one library or the All-libraries scope. // Family lens: a library_id outside the requester's visible set is a 403, // never a silent pass (AGENTS.md standing rule; history: 431fec0). // Omitted library_id aggregates across all visible libraries. No time // dimension — that is the reading tab's business. func (h *StatsHandler) LibraryUsage(c *echo.Context) error { user, ok := mustUser(c) if !ok { return echo.NewHTTPError(http.StatusUnauthorized, "invalid user") } var libraryID pgtype.UUID if raw := c.QueryParam("library_id"); raw != "" { parsed, err := uuid.Parse(raw) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, "invalid library id") } libraryID = pgtype.UUID{Bytes: parsed, Valid: true} } stats, err := h.statsService.LibraryUsage(c.Request().Context(), pgtype.UUID{Bytes: user.ID.Bytes, Valid: true}, libraryID, ) if err != nil { if errors.Is(err, services.ErrLibraryNotVisible) { return echo.NewHTTPError(http.StatusForbidden, "library not in the user's visible set") } return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } return c.JSON(http.StatusOK, stats) } // BookStats handles GET /api/stats/books/:id — the per-book card on the // book detail page (lifetime totals + recent sessions). func (h *StatsHandler) BookStats(c *echo.Context) error { user, ok := mustUser(c) if !ok { return echo.NewHTTPError(http.StatusUnauthorized, "invalid user") } mediaItemID, err := uuid.Parse(c.Param("id")) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, "invalid media item id") } stats, err := h.statsService.BookStats(c.Request().Context(), pgtype.UUID{Bytes: user.ID.Bytes, Valid: true}, mediaItemID, user.Timezone.String, ) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } return c.JSON(http.StatusOK, stats) }