/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.
640 lines
19 KiB
Go
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)
|
|
}
|