Files
bookhoard/internal/handlers/collections.go
T
john-okeefe b1ca813ba4 feat(collections): add helper functions for template rendering
Add data retrieval helpers for SSR template rendering:
- GetDeviceMappingsData: Fetch device shelf mappings for device settings UI
- GetUserCollectionsList: Get all user collections for dropdowns and listings
- GetCollectionData: Get single collection with metadata
- GetCollectionBooksData: Get books in a collection

These functions support the Phase 9 frontend features by providing
efficient data access for template rendering without modifying
existing API endpoints.
2026-02-01 00:26:20 -05:00

500 lines
17 KiB
Go

package handlers
import (
"bookmann/internal/database"
"bookmann/internal/services"
"encoding/json"
"net/http"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v4"
)
type CollectionHandler struct {
db *database.Queries
collectionService *services.CollectionService
}
func NewCollectionHandler(db *database.Queries) *CollectionHandler {
return &CollectionHandler{
db: db,
collectionService: services.NewCollectionService(db),
}
}
type CreateCollectionRequest struct {
Name string `json:"name" validate:"required"`
Description string `json:"description"`
Color string `json:"color"`
Icon string `json:"icon"`
AutoAssignRules []services.Rule `json:"auto_assign_rules"`
ViewSettings map[string]interface{} `json:"view_settings"`
}
type UpdateCollectionRequest struct {
Name string `json:"name" validate:"required"`
Description string `json:"description"`
Color string `json:"color"`
Icon string `json:"icon"`
AutoAssignRules []services.Rule `json:"auto_assign_rules"`
ViewSettings map[string]interface{} `json:"view_settings"`
}
type AddBooksRequest struct {
BookIDs []string `json:"book_ids" validate:"required"`
}
type CreateDeviceMappingRequest struct {
CollectionID string `json:"collection_id" validate:"required"`
DeviceShelfName string `json:"device_shelf_name" validate:"required"`
SyncDirection string `json:"sync_direction" validate:"required,oneof=bidirectional book_to_device device_to_book none"`
}
type UpdateDeviceMappingRequest struct {
DeviceShelfName string `json:"device_shelf_name" validate:"required"`
SyncDirection string `json:"sync_direction" validate:"required,oneof=bidirectional book_to_device device_to_book none"`
}
func (h *CollectionHandler) CreateCollection(c echo.Context) error {
user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes)
var req CreateCollectionRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
collection, err := h.collectionService.CreateCollection(
c.Request().Context(),
userUUID,
req.Name,
req.Description,
req.Color,
req.Icon,
req.AutoAssignRules,
req.ViewSettings,
)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
bookCount := int32(0)
return c.JSON(http.StatusCreated, map[string]interface{}{
"id": uuid.UUID(collection.ID.Bytes).String(),
"user_id": uuid.UUID(collection.UserID.Bytes).String(),
"name": collection.Name,
"description": textToString(collection.Description),
"color": textToString(collection.Color),
"icon": textToString(collection.Icon),
"auto_assign_rules": collection.AutoAssignRules,
"view_settings": collection.ViewSettings,
"book_count": bookCount,
"created_at": collection.CreatedAt.Time.String(),
})
}
func (h *CollectionHandler) GetCollections(c echo.Context) error {
includeAuto := c.QueryParam("include_auto") == "true"
sortBy := c.QueryParam("sort_by")
collections, err := h.GetCollectionsData(c)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
type CollectionResponse struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Color string `json:"color"`
Icon string `json:"icon"`
AutoAssignRules json.RawMessage `json:"auto_assign_rules"`
CreatedAt string `json:"created_at"`
}
response := make([]CollectionResponse, 0, len(collections))
for _, col := range collections {
if !includeAuto && len(col.AutoAssignRules) > 0 {
continue
}
response = append(response, CollectionResponse{
ID: uuid.UUID(col.ID.Bytes),
Name: col.Name,
Description: textToString(col.Description),
Color: textToString(col.Color),
Icon: textToString(col.Icon),
AutoAssignRules: json.RawMessage(col.AutoAssignRules),
CreatedAt: col.CreatedAt.Time.String(),
})
}
if sortBy == "name" {
for i := 0; i < len(response); i++ {
for j := i + 1; j < len(response); j++ {
if response[i].Name > response[j].Name {
response[i], response[j] = response[j], response[i]
}
}
}
}
return c.JSON(http.StatusOK, map[string]interface{}{
"collections": response,
"total": len(response),
})
}
func (h *CollectionHandler) GetCollection(c echo.Context) error {
collectionID, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
}
collection, err := h.GetCollectionData(c, collectionID)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"})
}
books, err := h.GetCollectionBooksData(c, collectionID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
type BookInfo struct {
MediaItemID string `json:"media_item_id"`
Title string `json:"title"`
Author string `json:"author"`
CoverImagePath string `json:"cover_image_path"`
}
bookList := make([]BookInfo, 0, len(books))
for _, book := range books {
bookList = append(bookList, BookInfo{
MediaItemID: uuid.UUID(book.MediaItemID.Bytes).String(),
Title: book.Title,
Author: textToString(book.Author),
CoverImagePath: textToString(book.CoverImagePath),
})
}
var viewSettings map[string]interface{}
if len(collection.ViewSettings) > 0 {
json.Unmarshal(collection.ViewSettings, &viewSettings)
}
return c.JSON(http.StatusOK, map[string]interface{}{
"id": uuid.UUID(collection.ID.Bytes).String(),
"user_id": uuid.UUID(collection.UserID.Bytes).String(),
"name": collection.Name,
"description": textToString(collection.Description),
"color": textToString(collection.Color),
"icon": textToString(collection.Icon),
"auto_assign_rules": json.RawMessage(collection.AutoAssignRules),
"view_settings": viewSettings,
"books": bookList,
"book_count": len(bookList),
"created_at": collection.CreatedAt.Time.String(),
})
}
func (h *CollectionHandler) UpdateCollection(c echo.Context) error {
collectionID, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
}
var req UpdateCollectionRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
collection, err := h.collectionService.UpdateCollection(
c.Request().Context(),
collectionID,
req.Name,
req.Description,
req.Color,
req.Icon,
req.AutoAssignRules,
req.ViewSettings,
)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"id": uuid.UUID(collection.ID.Bytes).String(),
"user_id": uuid.UUID(collection.UserID.Bytes).String(),
"name": collection.Name,
"description": textToString(collection.Description),
"color": textToString(collection.Color),
"icon": textToString(collection.Icon),
"auto_assign_rules": json.RawMessage(collection.AutoAssignRules),
"view_settings": json.RawMessage(collection.ViewSettings),
"created_at": collection.CreatedAt.Time.String(),
})
}
func (h *CollectionHandler) DeleteCollection(c echo.Context) error {
collectionID, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
}
err = h.collectionService.DeleteCollection(c.Request().Context(), collectionID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.NoContent(http.StatusNoContent)
}
func (h *CollectionHandler) AddBooks(c echo.Context) error {
collectionID, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
}
user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes)
var req AddBooksRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
for _, bookIDStr := range req.BookIDs {
bookID, err := uuid.Parse(bookIDStr)
if err != nil {
continue
}
h.collectionService.AddBookToCollection(c.Request().Context(), collectionID, bookID, userUUID)
}
return c.NoContent(http.StatusNoContent)
}
func (h *CollectionHandler) RemoveBook(c echo.Context) error {
collectionID, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
}
bookID, err := uuid.Parse(c.Param("bookId"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid book id"})
}
err = h.collectionService.RemoveBookFromCollection(c.Request().Context(), collectionID, bookID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.NoContent(http.StatusNoContent)
}
func (h *CollectionHandler) GetDeviceMappings(c echo.Context) error {
deviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device id"})
}
mappings, err := h.collectionService.GetDeviceShelfMappings(c.Request().Context(), deviceID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
type MappingResponse struct {
ID uuid.UUID `json:"id"`
CollectionID uuid.UUID `json:"collection_id"`
CollectionName string `json:"collection_name"`
DeviceShelfName string `json:"device_shelf_name"`
SyncDirection string `json:"sync_direction"`
CreatedAt string `json:"created_at"`
}
response := make([]MappingResponse, 0, len(mappings))
for _, m := range mappings {
response = append(response, MappingResponse{
ID: uuid.UUID(m.ID.Bytes),
CollectionID: uuid.UUID(m.CollectionID.Bytes),
CollectionName: m.CollectionName,
DeviceShelfName: textToString(m.DeviceShelfName),
SyncDirection: textToString(m.SyncDirection),
CreatedAt: m.CreatedAt.Time.String(),
})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"device_id": deviceID.String(),
"mappings": response,
"total": len(response),
})
}
func (h *CollectionHandler) CreateDeviceMapping(c echo.Context) error {
deviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device id"})
}
var req CreateDeviceMappingRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
collectionID, err := uuid.Parse(req.CollectionID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
}
mapping, err := h.collectionService.CreateDeviceShelfMapping(
c.Request().Context(),
collectionID,
deviceID,
req.DeviceShelfName,
req.SyncDirection,
)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusCreated, map[string]interface{}{
"id": uuid.UUID(mapping.ID.Bytes).String(),
"collection_id": uuid.UUID(mapping.CollectionID.Bytes).String(),
"device_id": uuid.UUID(mapping.DeviceID.Bytes).String(),
"device_shelf_name": textToString(mapping.DeviceShelfName),
"sync_direction": textToString(mapping.SyncDirection),
"created_at": mapping.CreatedAt.Time.String(),
})
}
func (h *CollectionHandler) UpdateDeviceMapping(c echo.Context) error {
mappingID, err := uuid.Parse(c.Param("mappingId"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid mapping id"})
}
var req UpdateDeviceMappingRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
mapping, err := h.collectionService.UpdateDeviceShelfMapping(
c.Request().Context(),
mappingID,
req.DeviceShelfName,
req.SyncDirection,
)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"id": uuid.UUID(mapping.ID.Bytes).String(),
"collection_id": uuid.UUID(mapping.CollectionID.Bytes).String(),
"device_id": uuid.UUID(mapping.DeviceID.Bytes).String(),
"device_shelf_name": textToString(mapping.DeviceShelfName),
"sync_direction": textToString(mapping.SyncDirection),
"created_at": mapping.CreatedAt.Time.String(),
})
}
func (h *CollectionHandler) DeleteDeviceMapping(c echo.Context) error {
mappingID, err := uuid.Parse(c.Param("mappingId"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid mapping id"})
}
err = h.collectionService.DeleteDeviceShelfMapping(c.Request().Context(), mappingID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.NoContent(http.StatusNoContent)
}
func (h *CollectionHandler) GetBookCollections(c echo.Context) error {
bookID, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid book id"})
}
collections, err := h.collectionService.GetBookCollections(c.Request().Context(), bookID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
type CollectionResponse struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Color string `json:"color"`
Icon string `json:"icon"`
}
response := make([]CollectionResponse, 0, len(collections))
for _, col := range collections {
response = append(response, CollectionResponse{
ID: uuid.UUID(col.ID.Bytes),
Name: col.Name,
Description: textToString(col.Description),
Color: textToString(col.Color),
Icon: textToString(col.Icon),
})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"book_id": bookID.String(),
"collections": response,
"total": len(response),
})
}
func textToString(t pgtype.Text) string {
if t.Valid {
return t.String
}
return ""
}
func (h *CollectionHandler) GetCollectionsData(c echo.Context) ([]database.Collections, error) {
user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes)
return h.collectionService.GetUserCollections(c.Request().Context(), userUUID)
}
func (h *CollectionHandler) GetCollectionData(c echo.Context, collectionID uuid.UUID) (database.Collections, error) {
return h.collectionService.GetCollection(c.Request().Context(), collectionID)
}
func (h *CollectionHandler) GetCollectionBooksData(c echo.Context, collectionID uuid.UUID) ([]database.GetCollectionItemsRow, error) {
return h.collectionService.GetCollectionBooks(c.Request().Context(), collectionID)
}
func (h *CollectionHandler) GetDeviceMappingsData(c echo.Context, deviceID uuid.UUID) ([]database.GetDeviceShelfMappingsRow, error) {
return h.collectionService.GetDeviceShelfMappings(c.Request().Context(), deviceID)
}
func (h *CollectionHandler) GetUserCollectionsList(c echo.Context) ([]database.Collections, error) {
user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes)
return h.collectionService.GetUserCollections(c.Request().Context(), userUUID)
}