refactor(handlers): Phase 4 - create MatchingHandler for sync operations
- Create MatchingHandler struct with db and connManager fields - Add NewMatchingHandler constructor - Add getMatchingService helper method - Move 12 matching methods from book_matching.go: * Core matching: QueryBooks, LinkBook, GetUnlinkedBooks, GetBookMatches * File aliases: GetDeviceFileAliases, CreateDeviceFileAlias, UpdateDeviceFileAlias, DeleteDeviceFileAlias * Bulk operations: BulkLinkBooks, AutoLinkBooks, GetUnlinkedBookSuggestions Methods copied (not moved) to maintain backward compatibility. Duplicates will be removed in Phase 7. This is Phase 4 of the ebook.go refactoring plan.
This commit is contained in:
@@ -0,0 +1,539 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/services"
|
||||
wsync "bookhoard/internal/sync"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// MatchingHandler handles book matching, linking, and file alias operations
|
||||
type MatchingHandler struct {
|
||||
db *database.Queries
|
||||
connManager *wsync.ConnectionManager
|
||||
}
|
||||
|
||||
// NewMatchingHandler creates a new matching handler
|
||||
func NewMatchingHandler(db *database.Queries, connManager *wsync.ConnectionManager) *MatchingHandler {
|
||||
return &MatchingHandler{
|
||||
db: db,
|
||||
connManager: connManager,
|
||||
}
|
||||
}
|
||||
|
||||
// getMatchingService creates a new book matching service
|
||||
func (mh *MatchingHandler) getMatchingService() *services.BookMatchingService {
|
||||
return services.NewBookMatchingService(mh.db)
|
||||
}
|
||||
|
||||
// QueryBooks handles POST /api/sync/books/query
|
||||
func (mh *MatchingHandler) QueryBooks(c echo.Context) error {
|
||||
matchingService := mh.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/devices/:deviceId/sync/link-book
|
||||
func (mh *MatchingHandler) LinkBook(c echo.Context) error {
|
||||
matchingService := mh.getMatchingService()
|
||||
|
||||
var req services.LinkBookRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Invalid request body",
|
||||
})
|
||||
}
|
||||
|
||||
deviceIDStr := c.Param("deviceId")
|
||||
deviceID, err := uuid.Parse(deviceIDStr)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Invalid device ID",
|
||||
})
|
||||
}
|
||||
|
||||
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/devices/:deviceId/sync/unlinked-books
|
||||
func (mh *MatchingHandler) GetUnlinkedBooks(c echo.Context) error {
|
||||
matchingService := mh.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",
|
||||
})
|
||||
}
|
||||
|
||||
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 (mh *MatchingHandler) 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 := mh.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",
|
||||
})
|
||||
}
|
||||
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
||||
// CreateDeviceFileAlias handles POST /api/devices/:id/file-aliases
|
||||
func (mh *MatchingHandler) 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 := mh.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 (mh *MatchingHandler) 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",
|
||||
})
|
||||
}
|
||||
|
||||
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 := mh.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 (mh *MatchingHandler) 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 = mh.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",
|
||||
})
|
||||
}
|
||||
|
||||
// GetBookMatches handles GET /api/books/match
|
||||
func (mh *MatchingHandler) GetBookMatches(c echo.Context) error {
|
||||
matchingService := mh.getMatchingService()
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// BulkLinkBooks handles POST /api/sync/bulk-link-books
|
||||
func (mh *MatchingHandler) 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 := mh.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 = mh.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},
|
||||
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 = mh.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 (mh *MatchingHandler) AutoLinkBooks(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
matchingService := mh.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 := mh.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 := mh.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 {
|
||||
mh.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 (mh *MatchingHandler) GetUnlinkedBookSuggestions(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
matchingService := mh.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 := mh.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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user