Files
bookhoard/internal/handlers/book_matching.go
T
John O'Keefe 040c10ac76 feat(matching): family-lens scoping for book matching and linking
/api/books/match returned media metadata from ALL libraries regardless
of visibility: the resolver lookups (GetMediaItemBySHA256 and friends)
are global, so match results leaked hidden-library titles, covers and
ids to any authenticated user, and LinkBook accepted a link target from
a hidden library. Both now enforce the family-lens rule:

- QueryBooks post-filters the cascade by the requester's visible-library
  set (absent-from-results — a hidden library's book simply never
  matches), before determineAction so the suggested action reflects the
  filtered set. BookQueryRequest gains a server-side-only
  VisibleLibraryIDs field; nil keeps legacy callers unfiltered.
- LinkBook verifies the device belongs to the caller
  (DeviceExistsForUser) and the target item's library is visible —
  403 otherwise. Linking was previously unscoped on both axes.
- GetUnlinkedBooks likewise verifies device ownership (its service is
  still a stub, but the route no longer answers for other users'
  devices).

Also: the collections books-grid partial uses a light loader —
requested library validated against the visible set directly instead of
the page-only library resolution (folder reachability, counts,
dropdown payload), removing the extra queries that made library
switching hold the spinner longer.

And the dashboard library switch now folds the This Week widget's
htmx refresh behind the spinner (refreshWeekWidget awaits the widget's
swap, with a safety timeout) instead of letting it pop in after the
fade-in — the two-stage update introduced with the widget's
library-scoped re-fetch.

Guardrail: the rendering lint now counts all innerHTML assignments (not
just template-literal ones) — the old pattern undercounted 20 files at
80 sites, including .join("") builders like renderDashboardCollections.
Baseline rebalanced accordingly.
2026-10-18 13:10:57 -04:00

640 lines
19 KiB
Go

package handlers
import (
"bookhoard/internal/database"
"bookhoard/internal/services"
"fmt"
"net/http"
"strconv"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
)
// Book matching service (added to Handler struct in scanner.go initialization)
func (h *Handler) getMatchingService() *services.BookMatchingService {
return services.NewBookMatchingService(h.db)
}
// BulkLinkBooksRequest represents a bulk linking request
type BulkLinkBooksRequest struct {
Links []struct {
UnlinkedBookID uuid.UUID `json:"unlinked_book_id"`
MediaItemID uuid.UUID `json:"media_item_id"`
ConfidenceScore float64 `json:"confidence_score"`
} `json:"links"`
}
// AutoLinkBooksRequest represents an auto-link request
type AutoLinkBooksRequest struct {
ConfidenceThreshold float64 `json:"confidence_threshold"`
Limit int `json:"limit"`
}
// Helper function to convert float64 to pgtype.Float8
func toFloat8(f float64) pgtype.Float8 {
var result pgtype.Float8
result.Scan(f)
return result
}
// QueryBooks handles POST /api/sync/books/query
func (h *Handler) QueryBooks(c *echo.Context) error {
matchingService := h.getMatchingService()
var req services.BookQueryRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "Invalid request body",
})
}
response, err := matchingService.QueryBooks(c.Request().Context(), &req)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "Failed to query books",
})
}
return c.JSON(http.StatusOK, response)
}
// LinkBook handles POST /api/sync/link-book
func (h *Handler) LinkBook(c *echo.Context) error {
matchingService := h.getMatchingService()
var req services.LinkBookRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "Invalid request body",
})
}
// Get device ID from context (set by auth middleware)
deviceIDStr := c.Param("deviceId")
deviceID, err := uuid.Parse(deviceIDStr)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "Invalid device ID",
})
}
// Ownership: the device must belong to the caller.
user, ok := c.Get("user").(database.Users)
if !ok {
return c.JSON(http.StatusUnauthorized, map[string]string{
"error": "user not authenticated",
})
}
deviceOwned, err := h.db.DeviceExistsForUser(c.Request().Context(), database.DeviceExistsForUserParams{
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
UserID: user.ID,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "Failed to verify device",
})
}
if !deviceOwned {
return c.JSON(http.StatusForbidden, map[string]string{
"error": "Device not found",
})
}
// Family-lens: the link target must sit in a library the caller can
// see — a hidden library's book can never be linked.
visibleSet, visErr := h.visibleLibrarySetFor(c)
if visErr != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "Failed to resolve visible libraries",
})
}
item, itemErr := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: req.MediaItemID, Valid: true})
if itemErr != nil {
return c.JSON(http.StatusNotFound, map[string]string{
"error": "Media item not found",
})
}
if !item.LibraryID.Valid || !visibleSet[uuid.UUID(item.LibraryID.Bytes).String()] {
return c.JSON(http.StatusForbidden, map[string]string{
"error": "Book is not available to you",
})
}
alias, err := matchingService.LinkBook(c.Request().Context(), deviceID, &req)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": fmt.Sprintf("Failed to link book: %v", err),
})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"status": "linked",
"device_file_alias": map[string]interface{}{
"id": uuid.UUID(alias.ID.Bytes).String(),
"media_item_id": uuid.UUID(alias.MediaItemID.Bytes).String(),
"device_id": uuid.UUID(alias.DeviceID.Bytes).String(),
"file_path": alias.FilePath,
"file_sha256": alias.FileSha256.String,
"confidence_score": alias.ConfidenceScore.Float64,
},
})
}
// GetUnlinkedBooks handles GET /api/sync/unlinked-books
func (h *Handler) GetUnlinkedBooks(c *echo.Context) error {
matchingService := h.getMatchingService()
deviceIDStr := c.Param("deviceId")
deviceID, err := uuid.Parse(deviceIDStr)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "Invalid device ID",
})
}
user, ok := c.Get("user").(database.Users)
if !ok {
return c.JSON(http.StatusUnauthorized, map[string]string{
"error": "user not authenticated",
})
}
deviceOwned, devErr := h.db.DeviceExistsForUser(c.Request().Context(), database.DeviceExistsForUserParams{
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
UserID: user.ID,
})
if devErr != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "Failed to verify device",
})
}
if !deviceOwned {
return c.JSON(http.StatusForbidden, map[string]string{
"error": "Device not found",
})
}
unlinked, err := matchingService.GetUnlinkedBooks(c.Request().Context(), deviceID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "Failed to get unlinked books",
})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"unlinked": unlinked,
"total": len(unlinked),
})
}
// GetDeviceFileAliases handles GET /api/devices/:id/file-aliases
func (h *Handler) GetDeviceFileAliases(c *echo.Context) error {
deviceIDStr := c.Param("id")
deviceID, err := uuid.Parse(deviceIDStr)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "Invalid device ID",
})
}
aliases, err := h.db.GetDeviceFileAliasesByDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceID, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "Failed to get file aliases",
})
}
// Convert to response format
type AliasResponse struct {
ID string `json:"id"`
MediaItemID string `json:"media_item_id"`
FilePath string `json:"file_path"`
FileSHA256 string `json:"file_sha256"`
ConfidenceScore float64 `json:"confidence_score"`
LastSeenAt string `json:"last_seen_at"`
}
response := make([]AliasResponse, len(aliases))
for i, alias := range aliases {
response[i] = AliasResponse{
ID: uuid.UUID(alias.ID.Bytes).String(),
MediaItemID: uuid.UUID(alias.MediaItemID.Bytes).String(),
FilePath: alias.FilePath,
FileSHA256: alias.FileSha256.String,
ConfidenceScore: alias.ConfidenceScore.Float64,
LastSeenAt: alias.LastSeenAt.Time.String(),
}
}
return c.JSON(http.StatusOK, map[string]interface{}{
"device_id": deviceIDStr,
"aliases": response,
"total": len(response),
})
}
// BulkLinkBooks handles POST /api/sync/bulk-link-books
func (h *Handler) BulkLinkBooks(c *echo.Context) error {
ctx := c.Request().Context()
var req BulkLinkBooksRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "Invalid request body",
})
}
results := make([]map[string]interface{}, 0, len(req.Links))
for _, link := range req.Links {
unlinkedBook, err := h.db.GetUnlinkedBookByID(ctx, pgtype.UUID{Bytes: link.UnlinkedBookID, Valid: true})
if err != nil {
results = append(results, map[string]interface{}{
"unlinked_book_id": link.UnlinkedBookID.String(),
"status": "error",
"error": "Unlinked book not found",
})
continue
}
_, err = h.db.CreateDeviceFileAlias(ctx, database.CreateDeviceFileAliasParams{
MediaItemID: pgtype.UUID{Bytes: link.MediaItemID, Valid: true},
DeviceID: unlinkedBook.DeviceID,
FilePath: unlinkedBook.FilePath.String,
FileSha256: pgtype.Text{String: "", Valid: false}, // SHA256 not stored in unlinked_books
ConfidenceScore: toFloat8(link.ConfidenceScore),
})
if err != nil {
results = append(results, map[string]interface{}{
"unlinked_book_id": link.UnlinkedBookID.String(),
"status": "error",
"error": err.Error(),
})
continue
}
_, err = h.db.LinkUnlinkedBook(ctx, database.LinkUnlinkedBookParams{
ID: pgtype.UUID{Bytes: link.UnlinkedBookID, Valid: true},
MediaItemID: pgtype.UUID{Bytes: link.MediaItemID, Valid: true},
ConfidenceScore: toFloat8(link.ConfidenceScore),
})
if err != nil {
results = append(results, map[string]interface{}{
"unlinked_book_id": link.UnlinkedBookID.String(),
"status": "warning",
"error": "Linked but failed to mark as resolved",
})
continue
}
results = append(results, map[string]interface{}{
"unlinked_book_id": link.UnlinkedBookID.String(),
"status": "success",
"media_item_id": link.MediaItemID.String(),
})
}
successfulCount := 0
for _, r := range results {
if r["status"] == "success" {
successfulCount++
}
}
return c.JSON(http.StatusOK, map[string]interface{}{
"results": results,
"total": len(req.Links),
"successful": successfulCount,
"failed": len(req.Links) - successfulCount,
})
}
// AutoLinkBooks handles POST /api/sync/auto-link-books
func (h *Handler) AutoLinkBooks(c *echo.Context) error {
ctx := c.Request().Context()
matchingService := h.getMatchingService()
var req AutoLinkBooksRequest
if err := c.Bind(&req); err != nil {
req.ConfidenceThreshold = 0.8
req.Limit = 50
}
if req.ConfidenceThreshold == 0 {
req.ConfidenceThreshold = 0.8
}
if req.Limit == 0 {
req.Limit = 50
}
offset := 0
unlinked, err := h.db.ListUnresolvedUnlinkedBooks(ctx, database.ListUnresolvedUnlinkedBooksParams{
Limit: int32(req.Limit),
Offset: int32(offset),
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "Failed to get unlinked books",
})
}
results := make([]map[string]interface{}, 0)
for _, book := range unlinked {
queryReq := &services.BookQueryRequest{
Title: book.Title.String,
}
match, err := matchingService.QueryBooks(ctx, queryReq)
if err != nil {
continue
}
if len(match.Matches) > 0 && match.Matches[0].Confidence >= req.ConfidenceThreshold {
bestMatch := match.Matches[0]
_, err := h.db.CreateDeviceFileAlias(ctx, database.CreateDeviceFileAliasParams{
MediaItemID: pgtype.UUID{Bytes: bestMatch.BookhoardUUID, Valid: true},
DeviceID: book.DeviceID,
FilePath: book.FilePath.String,
FileSha256: pgtype.Text{String: "", Valid: false},
ConfidenceScore: toFloat8(bestMatch.Confidence),
})
if err == nil {
h.db.LinkUnlinkedBook(ctx, database.LinkUnlinkedBookParams{
ID: book.ID,
MediaItemID: pgtype.UUID{Bytes: bestMatch.BookhoardUUID, Valid: true},
ConfidenceScore: toFloat8(bestMatch.Confidence),
})
results = append(results, map[string]interface{}{
"unlinked_book_id": uuid.UUID(book.ID.Bytes).String(),
"title": book.Title.String,
"matched_media_item_id": bestMatch.BookhoardUUID.String(),
"confidence": bestMatch.Confidence,
"match_method": bestMatch.MatchMethod,
})
}
}
}
return c.JSON(http.StatusOK, map[string]interface{}{
"auto_linked": len(results),
"results": results,
})
}
// GetUnlinkedBookSuggestions handles GET /api/sync/unlinked-books/:id/suggestions
func (h *Handler) GetUnlinkedBookSuggestions(c *echo.Context) error {
ctx := c.Request().Context()
matchingService := h.getMatchingService()
unlinkedBookID := c.Param("id")
unlinkedUUID, err := uuid.Parse(unlinkedBookID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "Invalid unlinked book ID",
})
}
unlinked, err := h.db.GetUnlinkedBookByID(ctx, pgtype.UUID{Bytes: unlinkedUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{
"error": "Unlinked book not found",
})
}
matches, err := matchingService.QueryBooks(ctx, &services.BookQueryRequest{
Title: unlinked.Title.String,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "Failed to query matches",
})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"unlinked_book_id": unlinkedBookID,
"title_from_device": unlinked.Title.String,
"sha256": "",
"suggestions": matches.Matches,
"total_suggestions": len(matches.Matches),
"action": matches.Action,
})
}
// CreateDeviceFileAlias handles POST /api/devices/:id/file-aliases
func (h *Handler) CreateDeviceFileAlias(c *echo.Context) error {
deviceIDStr := c.Param("id")
deviceID, err := uuid.Parse(deviceIDStr)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "Invalid device ID",
})
}
var req struct {
MediaItemID string `json:"media_item_id"`
FilePath string `json:"file_path"`
FileSHA256 string `json:"file_sha256"`
ConfidenceScore float64 `json:"confidence_score"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "Invalid request body",
})
}
mediaItemID, err := uuid.Parse(req.MediaItemID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "Invalid media item ID",
})
}
alias, err := h.db.CreateDeviceFileAlias(c.Request().Context(), database.CreateDeviceFileAliasParams{
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
FilePath: req.FilePath,
FileSha256: pgtype.Text{String: req.FileSHA256, Valid: req.FileSHA256 != ""},
ConfidenceScore: pgtype.Float8{Float64: req.ConfidenceScore, Valid: true},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": fmt.Sprintf("Failed to create file alias: %v", err),
})
}
return c.JSON(http.StatusCreated, map[string]interface{}{
"id": uuid.UUID(alias.ID.Bytes).String(),
"media_item_id": uuid.UUID(alias.MediaItemID.Bytes).String(),
"device_id": deviceIDStr,
"file_path": alias.FilePath,
"file_sha256": alias.FileSha256.String,
"confidence_score": alias.ConfidenceScore.Float64,
"last_seen_at": alias.LastSeenAt.Time.String(),
})
}
// UpdateDeviceFileAlias handles PUT /api/devices/:id/file-aliases/:aliasId
func (h *Handler) UpdateDeviceFileAlias(c *echo.Context) error {
aliasIDStr := c.Param("aliasId")
aliasID, err := uuid.Parse(aliasIDStr)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "Invalid alias ID",
})
}
var req struct {
MediaItemID *string `json:"media_item_id"`
FileSHA256 *string `json:"file_sha256"`
ConfidenceScore *float64 `json:"confidence_score"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "Invalid request body",
})
}
// Build update parameters
var mediaItemID pgtype.UUID
if req.MediaItemID != nil {
parsedID, err := uuid.Parse(*req.MediaItemID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "Invalid media item ID",
})
}
mediaItemID = pgtype.UUID{Bytes: parsedID, Valid: true}
}
var fileSHA256 pgtype.Text
if req.FileSHA256 != nil {
fileSHA256 = pgtype.Text{String: *req.FileSHA256, Valid: true}
}
var confidenceScore pgtype.Float8
if req.ConfidenceScore != nil {
confidenceScore = pgtype.Float8{Float64: *req.ConfidenceScore, Valid: true}
}
alias, err := h.db.UpdateDeviceFileAlias(c.Request().Context(), database.UpdateDeviceFileAliasParams{
ID: pgtype.UUID{Bytes: aliasID, Valid: true},
MediaItemID: mediaItemID,
FileSha256: fileSHA256,
ConfidenceScore: confidenceScore,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": fmt.Sprintf("Failed to update file alias: %v", err),
})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"id": uuid.UUID(alias.ID.Bytes).String(),
"media_item_id": uuid.UUID(alias.MediaItemID.Bytes).String(),
"file_path": alias.FilePath,
"file_sha256": alias.FileSha256.String,
"confidence_score": alias.ConfidenceScore.Float64,
"last_seen_at": alias.LastSeenAt.Time.String(),
})
}
// DeleteDeviceFileAlias handles DELETE /api/devices/:id/file-aliases/:aliasId
func (h *Handler) DeleteDeviceFileAlias(c *echo.Context) error {
aliasIDStr := c.Param("aliasId")
aliasID, err := uuid.Parse(aliasIDStr)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "Invalid alias ID",
})
}
err = h.db.DeleteDeviceFileAlias(c.Request().Context(), pgtype.UUID{Bytes: aliasID, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "Failed to delete file alias",
})
}
return c.JSON(http.StatusOK, map[string]string{
"message": "File alias deleted successfully",
})
}
// visibleLibrarySetFor resolves the requesting user's visible-library set
// (family-lens) from the request context identity.
func (h *Handler) visibleLibrarySetFor(c *echo.Context) (map[string]bool, error) {
user, ok := c.Get("user").(database.Users)
if !ok {
return nil, echo.NewHTTPError(http.StatusUnauthorized, "user not authenticated")
}
set, _, err := services.NewLibraryService(h.db).VisibleLibrarySet(
c.Request().Context(), user.ID)
return set, err
}
// GetBookMatches handles GET /api/books/match
func (h *Handler) GetBookMatches(c *echo.Context) error {
matchingService := h.getMatchingService()
// Family-lens: restrict matches to libraries the requester can see
// (absent-from-results — a hidden library's book simply never matches).
visibleSet, visErr := h.visibleLibrarySetFor(c)
if visErr != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "Failed to resolve visible libraries",
})
}
visibleIDs := make([]string, 0, len(visibleSet))
for id := range visibleSet {
visibleIDs = append(visibleIDs, id)
}
// Get query parameters
identifiers := c.QueryParams()["identifier"]
sha256 := c.QueryParam("sha256")
title := c.QueryParam("title")
author := c.QueryParam("author")
fileSizeStr := c.QueryParam("file_size")
var fileSize int64
if fileSizeStr != "" {
size, err := strconv.ParseInt(fileSizeStr, 10, 64)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "Invalid file_size parameter",
})
}
fileSize = size
}
req := &services.BookQueryRequest{
Identifiers: identifiers,
SHA256: sha256,
Title: title,
Author: author,
FileSize: fileSize,
VisibleLibraryIDs: visibleIDs,
}
response, err := matchingService.QueryBooks(c.Request().Context(), req)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "Failed to query books",
})
}
return c.JSON(http.StatusOK, response)
}