Files
bookhoard/internal/handlers/series.go
T
John O'Keefe d1f9ad3b25 feat(series): sort and filter the series page
The /series page rendered its grid in one fixed order (newest first)
with no way to find a series beyond eyeballing cards. The page now has
a toolbar — series-name search, author filter, viewer-progress filter
and a 12-key sort select — wired as a plain GET form (the page's
existing model: the URL is the single source of truth, and the library
switcher now carries q/author/status/sort along on switch).

Extensibility is deliberate (user request): sorts live in a registry
(services.ResolveSeriesSort) mirrored by a CASE cascade in
GetDistinctSeries and a <select> in series.templ — a new sort is one
entry in each. The sort value is a BOUND PARAM compared inside the
cascade, never interpolated; unknown keys resolve to the default.
Filters: series-name matches in WHERE (the group key, so counts stay
true); author (BOOL_OR any-book match) and status (finished/reading/
unstarted vs the viewer's progress, same 0.999 threshold as the
filter_query status arms) in HAVING so COUNT(*) keeps reporting the
whole series. GetDistinctSeriesCount shares the JOIN + HAVING so
pagination totals match. The reading_progress join can't multiply rows
(the table is UNIQUE per media_item+user).

Cards gain the viewer's finished-count ("X of N read" + progress bar),
and the empty state distinguishes "no series at all" from "no matches —
clear filters". The JSON /api/series endpoint takes the same params and
gains read_count/last_read_at additively.

Integration test pins every sort key against a three-series fixture
with distinct created/author/count/progress answers, the filter matrix,
composition, unknown-sort fallback, card read-counts and the JSON
contract — all as the fresh regular user so the live reader's data is
untouched.
2026-10-27 14:53:45 -04:00

173 lines
5.3 KiB
Go

package handlers
import (
"bookhoard/internal/database"
"bookhoard/internal/services"
"context"
"net/http"
"strconv"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
)
type SeriesHandler struct {
seriesService *services.SeriesService
libraryService *services.LibraryService
db *database.Queries
}
func NewSeriesHandler(db *database.Queries) *SeriesHandler {
return &SeriesHandler{
seriesService: services.NewSeriesService(db),
libraryService: services.NewLibraryService(db),
db: db,
}
}
func (h *SeriesHandler) GetSeries(c *echo.Context) error {
libraryID := c.QueryParam("library_id")
var libUUID pgtype.UUID
if libraryID != "" {
parsed, err := uuid.Parse(libraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
}
limit := 20
if l := c.QueryParam("limit"); l != "" {
if v, err := strconv.Atoi(l); err == nil && v > 0 {
limit = v
if limit > 100 {
limit = 100
}
}
}
offset := 0
if o := c.QueryParam("offset"); o != "" {
if v, err := strconv.Atoi(o); err == nil && v >= 0 {
offset = v
}
}
// Visibility scoping: an explicit library_id must be visible to the
// requester (403 otherwise); the unscoped aggregate covers only the
// requester's visible libraries.
visibleSet, visibleIDs, err := h.visibleLibrarySet(c)
if err != nil {
return err
}
if libraryID != "" && !visibleSet[libraryID] {
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
}
// Sort/filter params (same contract as the SSR /series page):
// unknown values resolve to defaults inside the service.
opts := services.SeriesPageOptions{
Sort: c.QueryParam("sort"),
Query: c.QueryParam("q"),
Author: c.QueryParam("author"),
Status: c.QueryParam("status"),
}
if u, ok := c.Get("user").(database.Users); ok {
opts.ViewerUserID = u.ID
}
seriesList, total, err := h.seriesService.GetSeriesPage(c.Request().Context(), libUUID, visibleIDs, limit, offset, opts)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load series"})
}
type SeriesResponse struct {
Name string `json:"name"`
LibraryID string `json:"library_id"`
BookCount int64 `json:"book_count"`
TotalInSeries int `json:"total_in_series"`
CoverPaths []string `json:"cover_paths"`
LastEntryAt string `json:"last_entry_at"`
ReadCount int64 `json:"read_count"`
LastReadAt string `json:"last_read_at"`
}
response := make([]SeriesResponse, 0, len(seriesList))
for _, s := range seriesList {
response = append(response, SeriesResponse{
Name: s.Name,
LibraryID: s.LibraryID,
BookCount: s.BookCount,
TotalInSeries: s.TotalInSeries,
CoverPaths: s.CoverPaths,
LastEntryAt: s.LastEntryAt,
ReadCount: s.ReadCount,
LastReadAt: s.LastReadAt,
})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"series": response,
"total": total,
"limit": limit,
"offset": offset,
})
}
// visibleLibrarySet loads the requester's visible libraries. Every series
// path funnels through this: hidden libraries contribute nothing, and an
// explicit library_id outside the set is a 403, not a silent pass.
func (h *SeriesHandler) visibleLibrarySet(c *echo.Context) (map[string]bool, []pgtype.UUID, error) {
user, ok := c.Get("user").(database.Users)
if !ok {
return nil, nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
}
return h.libraryService.VisibleLibrarySet(c.Request().Context(), user.ID)
}
func (h *SeriesHandler) GetSeriesBooks(c *echo.Context) error {
seriesName := c.QueryParam("name")
if seriesName == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "name required"})
}
libraryID := c.QueryParam("library_id")
if libraryID == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id required"})
}
libUUID, err := uuid.Parse(libraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
visibleSet, _, err := h.visibleLibrarySet(c)
if err != nil {
return err
}
if !visibleSet[libraryID] {
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
}
books, err := h.seriesService.GetSeriesBooks(c.Request().Context(), pgtype.UUID{Bytes: libUUID, Valid: true}, seriesName)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load series books"})
}
bookCards := BookCardsFromMediaItems(books, LibraryTypeMap(c.Request().Context(), h.db))
if u, ok := c.Get("user").(database.Users); ok {
bookCards = MarkActiveConflicts(c.Request().Context(), h.db, u.ID, bookCards)
}
return c.JSON(http.StatusOK, map[string]interface{}{
"name": seriesName,
"library_id": libraryID,
"books": bookCards,
"total": len(bookCards),
})
}
func GetSeriesCardsData(ctx context.Context, db *database.Queries, libraryID pgtype.UUID, visibleLibraryIDs []pgtype.UUID, limit, offset int, opts services.SeriesPageOptions) ([]services.SeriesInfo, int, error) {
svc := services.NewSeriesService(db)
return svc.GetSeriesPage(ctx, libraryID, visibleLibraryIDs, limit, offset, opts)
}