Series' visibleLibrarySet was the third hand-rolled set builder (with its own FromBytes-error idiom, the fourth idiom in four copies). SeriesService.VisibleLibraries — a pure pass-through to GetUserVisibleLibraries — now has zero callers and dies with the migration.
157 lines
4.7 KiB
Go
157 lines
4.7 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"})
|
|
}
|
|
|
|
seriesList, total, err := h.seriesService.GetSeriesPage(c.Request().Context(), libUUID, visibleIDs, limit, offset)
|
|
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"`
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
|
|
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) ([]services.SeriesInfo, int, error) {
|
|
svc := services.NewSeriesService(db)
|
|
return svc.GetSeriesPage(ctx, libraryID, visibleLibraryIDs, limit, offset)
|
|
}
|