Files
bookhoard/internal/handlers/conflicts.go
T
john-okeefe ad27902790 fix(conflicts): use ListConflictsByUser in DismissAllResolved so resolved conflicts are found
DismissAllResolved was calling ListSyncConflictsByUser which filters to
'unresolved' conflicts only, so it could never find the user_resolved or
bulk_resolved conflicts it was trying to delete. The query always returned
an empty set, making dismiss-all a no-op.

Fix the leading space in three SQL query name annotations (ListConflictsByUser,
ListAllConflictsByUserAndStatus, CheckForProgressConflicts) that prevented
sqlc from generating their Go functions. Regenerate the query code and swap
DismissAllResolved to use ListConflictsByUser (no status filter) — the
existing Go loop already filters by resolution_status.
2026-04-21 21:15:34 -04:00

782 lines
22 KiB
Go

package handlers
import (
"bookhoard/internal/database"
wsync "bookhoard/internal/sync"
"context"
"encoding/json"
"errors"
"net/http"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
)
type ConflictHandler struct {
db *database.Queries
connManager *wsync.ConnectionManager
}
func NewConflictHandler(db *database.Queries, connManager *wsync.ConnectionManager) *ConflictHandler {
return &ConflictHandler{
db: db,
connManager: connManager,
}
}
type ConflictResolutionRequest struct {
Winner string `json:"winner" validate:"required,oneof=koreader kobo web manual"`
ManualData map[string]interface{} `json:"manual_data"`
ApplyToAll bool `json:"apply_to_all_future_conflicts"`
Reason string `json:"reason"`
}
type ConflictSourceData struct {
Source string `json:"source"`
Timestamp time.Time `json:"timestamp"`
Data map[string]interface{} `json:"data"`
}
type ConflictDetailResponse struct {
ID string `json:"id"`
MediaItemID string `json:"media_item_id"`
MediaItemTitle string `json:"media_item_title"`
ConflictType string `json:"conflict_type"`
ConflictData map[string]ConflictSourceData `json:"conflict_data"`
ResolutionStatus string `json:"resolution_status"`
ResolutionData map[string]interface{} `json:"resolution_data,omitempty"`
ResolvedBy string `json:"resolved_by,omitempty"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type ConflictListResponse struct {
Conflicts []ConflictDetailResponse `json:"conflicts"`
Total int `json:"total"`
Unresolved int `json:"unresolved"`
}
type ConflictResolveResponse struct {
ConflictResolved bool `json:"conflict_resolved"`
AppliedTo map[string]bool `json:"applied_to"`
DevicesSynced []string `json:"devices_synced"`
}
func (h *ConflictHandler) GetConflictsData(c *echo.Context) ([]ConflictDetailResponse, int, int, error) {
user := c.Get("user").(database.Users)
status := c.QueryParam("status")
if status == "" {
status = "unresolved"
}
ctx := context.Background()
var conflicts interface{}
var err error
if status == "all" {
conflicts, err = h.db.ListSyncConflictsByUser(ctx, user.ID)
} else {
conflicts, err = h.db.ListSyncConflictsByUser(ctx, user.ID)
}
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, 0, 0, err
}
response := make([]ConflictDetailResponse, 0, len(conflicts.([]database.ListSyncConflictsByUserRow)))
unresolvedCount := 0
for _, conflict := range conflicts.([]database.ListSyncConflictsByUserRow) {
var conflictData map[string]ConflictSourceData
if err := json.Unmarshal(conflict.ConflictData, &conflictData); err != nil {
continue
}
detail := ConflictDetailResponse{
ID: uuid.UUID(conflict.ID.Bytes).String(),
MediaItemID: uuid.UUID(conflict.MediaItemID.Bytes).String(),
MediaItemTitle: conflict.Title,
ConflictType: conflict.ConflictType,
ConflictData: conflictData,
ResolutionStatus: conflict.ResolutionStatus.String,
CreatedAt: conflict.CreatedAt.Time,
}
if conflict.ResolvedBy.Valid {
detail.ResolvedBy = uuid.UUID(conflict.ResolvedBy.Bytes).String()
}
if conflict.ResolvedAt.Valid {
detail.ResolvedAt = &conflict.ResolvedAt.Time
}
if conflict.ResolutionData != nil {
if err := json.Unmarshal(conflict.ResolutionData, &detail.ResolutionData); err == nil {
}
}
response = append(response, detail)
if conflict.ResolutionStatus.String == "unresolved" {
unresolvedCount++
}
}
return response, len(response), unresolvedCount, nil
}
func (h *ConflictHandler) ListConflicts(c *echo.Context) error {
conflicts, total, unresolved, err := h.GetConflictsData(c)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to list conflicts")
}
return c.JSON(http.StatusOK, ConflictListResponse{
Conflicts: conflicts,
Total: total,
Unresolved: unresolved,
})
}
func (h *ConflictHandler) GetConflict(c *echo.Context) error {
user := c.Get("user").(database.Users)
conflictID, err := uuid.Parse(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid conflict ID")
}
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return echo.NewHTTPError(http.StatusNotFound, "conflict not found")
}
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get conflict")
}
if conflict.UserID.Bytes != user.ID.Bytes {
return echo.NewHTTPError(http.StatusForbidden, "access denied")
}
mediaItem, err := h.db.GetMediaItem(context.Background(), conflict.MediaItemID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get media item")
}
var conflictData map[string]ConflictSourceData
if err := json.Unmarshal(conflict.ConflictData, &conflictData); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to parse conflict data")
}
detail := ConflictDetailResponse{
ID: uuid.UUID(conflict.ID.Bytes).String(),
MediaItemID: uuid.UUID(conflict.MediaItemID.Bytes).String(),
MediaItemTitle: mediaItem.Title,
ConflictType: conflict.ConflictType,
ConflictData: conflictData,
ResolutionStatus: conflict.ResolutionStatus.String,
CreatedAt: conflict.CreatedAt.Time,
}
if conflict.ResolvedBy.Valid {
detail.ResolvedBy = uuid.UUID(conflict.ResolvedBy.Bytes).String()
}
if conflict.ResolvedAt.Valid {
detail.ResolvedAt = &conflict.ResolvedAt.Time
}
if conflict.ResolutionData != nil {
if err := json.Unmarshal(conflict.ResolutionData, &detail.ResolutionData); err == nil {
}
}
return c.JSON(http.StatusOK, detail)
}
func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
user := c.Get("user").(database.Users)
conflictID, err := uuid.Parse(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid conflict ID")
}
var req ConflictResolutionRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid request body")
}
if req.Winner == "manual" && req.ManualData == nil {
return echo.NewHTTPError(http.StatusBadRequest, "manual_data required when winner is manual")
}
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return echo.NewHTTPError(http.StatusNotFound, "conflict not found")
}
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get conflict")
}
if conflict.UserID.Bytes != user.ID.Bytes {
return echo.NewHTTPError(http.StatusForbidden, "access denied")
}
if conflict.ResolutionStatus.String != "unresolved" {
return echo.NewHTTPError(http.StatusBadRequest, "conflict already resolved")
}
var conflictData map[string]ConflictSourceData
if err := json.Unmarshal(conflict.ConflictData, &conflictData); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to parse conflict data")
}
winnerData := map[string]interface{}{}
if req.Winner == "manual" {
winnerData = req.ManualData
} else {
source, ok := conflictData[req.Winner]
if !ok {
return echo.NewHTTPError(http.StatusBadRequest, "invalid winner source")
}
winnerData = source.Data
}
appliedTo := map[string]bool{
"progress": false,
"annotations": false,
}
if conflict.ConflictType == "progress" {
if err := h.applyProgressResolution(conflict.MediaItemID, conflict.UserID, winnerData); err == nil {
appliedTo["progress"] = true
}
}
resolutionData := map[string]interface{}{
"winner": req.Winner,
"applied_to": appliedTo,
"reason": req.Reason,
"resolved_at": time.Now(),
}
resolutionDataJSON, _ := json.Marshal(resolutionData)
_, err = h.db.ResolveSyncConflict(context.Background(), database.ResolveSyncConflictParams{
ID: conflictUUID,
ResolutionStatus: pgtype.Text{String: "user_resolved", Valid: true},
ResolutionData: resolutionDataJSON,
ResolvedBy: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to resolve conflict")
}
devicesSynced := h.notifyDevicesOfResolution(conflict.MediaItemID, winnerData)
response := ConflictResolveResponse{
ConflictResolved: true,
AppliedTo: appliedTo,
DevicesSynced: devicesSynced,
}
return c.JSON(http.StatusOK, response)
}
func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, data map[string]interface{}) error {
ctx := context.Background()
existingProgress, err := h.db.GetReadingProgress(ctx, database.GetReadingProgressParams{
MediaItemID: mediaItemID,
UserID: userID,
})
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return err
}
percentage := 0.0
if p, ok := data["percentage"].(float64); ok {
percentage = p
}
var epubcfi pgtype.Text
if e, ok := data["epubcfi"].(string); ok {
epubcfi = pgtype.Text{String: e, Valid: true}
}
var chapter pgtype.Int4
if c, ok := data["chapter"].(float64); ok {
chapter = pgtype.Int4{Int32: int32(c), Valid: true}
}
var characterOffset pgtype.Int8
if c, ok := data["character"].(float64); ok {
characterOffset = pgtype.Int8{Int64: int64(c), Valid: true}
}
var currentPage pgtype.Int4
var totalPages pgtype.Int4
if err == nil {
currentPage = existingProgress.CurrentPage
totalPages = existingProgress.TotalPages
}
if p, ok := data["page"].(float64); ok {
currentPage = pgtype.Int4{Int32: int32(p), Valid: true}
}
if p, ok := data["total_pages"].(float64); ok {
totalPages = pgtype.Int4{Int32: int32(p), Valid: true}
}
_, err = h.db.UpdateUniversalProgress(ctx, database.UpdateUniversalProgressParams{
MediaItemID: mediaItemID,
UserID: userID,
Percentage: pgtype.Float8{Float64: percentage, Valid: true},
Epubcfi: epubcfi,
Chapter: chapter,
ChapterProgress: pgtype.Float8{Float64: percentage, Valid: true},
CharacterOffset: characterOffset,
CurrentPage: currentPage,
TotalPages: totalPages,
LastSyncDevice: pgtype.Text{String: "conflict_resolution", Valid: true},
LastSyncSource: pgtype.Text{String: "manual", Valid: true},
ViewportY: pgtype.Float8{},
ScrollPositionX: pgtype.Float8{},
ScrollPositionY: pgtype.Float8{},
PanelNumber: pgtype.Int4{},
ReadingMode: pgtype.Text{},
ZoomLevel: pgtype.Float8{},
})
return err
}
func (h *ConflictHandler) notifyDevicesOfResolution(mediaItemID pgtype.UUID, data map[string]interface{}) []string {
devices, err := h.db.ListDevicesByType(context.Background(), "koreader")
if err != nil {
return []string{}
}
synced := []string{}
for _, device := range devices {
if device.SyncEnabled.Bool {
synced = append(synced, uuid.UUID(device.ID.Bytes).String())
}
}
return synced
}
func (h *ConflictHandler) DeleteConflict(c *echo.Context) error {
user := c.Get("user").(database.Users)
conflictID, err := uuid.Parse(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid conflict ID")
}
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return echo.NewHTTPError(http.StatusNotFound, "conflict not found")
}
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get conflict")
}
if conflict.UserID.Bytes != user.ID.Bytes {
return echo.NewHTTPError(http.StatusForbidden, "access denied")
}
if err := h.db.DeleteSyncConflict(context.Background(), conflictUUID); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to delete conflict")
}
return c.NoContent(http.StatusNoContent)
}
func (h *ConflictHandler) DismissAllResolved(c *echo.Context) error {
user := c.Get("user").(database.Users)
conflicts, err := h.db.ListConflictsByUser(context.Background(), user.ID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to list conflicts")
}
deleted := 0
for _, conflict := range conflicts {
if conflict.ResolutionStatus.String == "user_resolved" || conflict.ResolutionStatus.String == "bulk_resolved" {
if err := h.db.DeleteSyncConflict(context.Background(), conflict.ID); err == nil {
deleted++
}
}
}
return c.JSON(http.StatusOK, map[string]interface{}{
"deleted": deleted,
})
}
type BulkResolveRequest struct {
ConflictIDs []string `json:"conflict_ids" validate:"required"`
Strategy string `json:"strategy" validate:"required,oneof=most_recent highest_progress manual"`
WinningSource string `json:"winning_source,omitempty"`
}
type BulkResolveResponse struct {
Results []ConflictResult `json:"results"`
Total int `json:"total"`
Success int `json:"success"`
Failed int `json:"failed"`
}
type ConflictResult struct {
ConflictID string `json:"conflict_id"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
Winner string `json:"winner,omitempty"`
}
func (h *ConflictHandler) BulkResolveConflicts(c *echo.Context) error {
user := c.Get("user").(database.Users)
var req BulkResolveRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid request body")
}
if len(req.ConflictIDs) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "conflict_ids required")
}
results := make([]ConflictResult, 0, len(req.ConflictIDs))
successCount := 0
failedCount := 0
for _, conflictIDStr := range req.ConflictIDs {
conflictID, err := uuid.Parse(conflictIDStr)
if err != nil {
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
Status: "error",
Error: "invalid conflict ID",
})
failedCount++
continue
}
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID)
if err != nil {
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
Status: "error",
Error: "conflict not found",
})
failedCount++
continue
}
if conflict.UserID.Bytes != user.ID.Bytes {
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
Status: "error",
Error: "access denied",
})
failedCount++
continue
}
var conflictData map[string]ConflictSourceData
if err := json.Unmarshal(conflict.ConflictData, &conflictData); err != nil {
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
Status: "error",
Error: "failed to parse conflict data",
})
failedCount++
continue
}
var winningSource string
var winnerData map[string]interface{}
switch req.Strategy {
case "most_recent":
winningSource, winnerData = h.getMostRecentSource(conflictData)
case "highest_progress":
winningSource, winnerData = h.getHighestProgressSource(conflictData)
case "manual":
if req.WinningSource == "" {
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
Status: "error",
Error: "winning_source required for manual strategy",
})
failedCount++
continue
}
source, ok := conflictData[req.WinningSource]
if !ok {
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
Status: "error",
Error: "invalid winning source",
})
failedCount++
continue
}
winningSource = req.WinningSource
winnerData = source.Data
default:
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
Status: "error",
Error: "invalid strategy",
})
failedCount++
continue
}
if winnerData == nil {
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
Status: "error",
Error: "failed to determine winner",
})
failedCount++
continue
}
if err := h.applyResolution(conflict.MediaItemID, conflict.UserID, winnerData); err != nil {
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
Status: "error",
Error: "failed to apply resolution",
})
failedCount++
continue
}
resolutionData := map[string]interface{}{
"winner": winningSource,
"strategy": req.Strategy,
"resolved_at": time.Now(),
}
resolutionDataJSON, _ := json.Marshal(resolutionData)
_, err = h.db.ResolveSyncConflict(context.Background(), database.ResolveSyncConflictParams{
ID: conflictUUID,
ResolutionStatus: pgtype.Text{String: "bulk_resolved", Valid: true},
ResolutionData: resolutionDataJSON,
ResolvedBy: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
})
if err != nil {
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
Status: "error",
Error: "failed to mark as resolved",
})
failedCount++
continue
}
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
Status: "success",
Winner: winningSource,
})
successCount++
}
return c.JSON(http.StatusOK, BulkResolveResponse{
Results: results,
Total: len(req.ConflictIDs),
Success: successCount,
Failed: failedCount,
})
}
func (h *ConflictHandler) getMostRecentSource(conflictData map[string]ConflictSourceData) (string, map[string]interface{}) {
var recentSource string
var recentTime time.Time
var recentData map[string]interface{}
for source, data := range conflictData {
if data.Timestamp.After(recentTime) {
recentTime = data.Timestamp
recentSource = source
recentData = data.Data
}
}
return recentSource, recentData
}
func (h *ConflictHandler) getHighestProgressSource(conflictData map[string]ConflictSourceData) (string, map[string]interface{}) {
var highestSource string
var highestPercentage float64 = -1
var highestData map[string]interface{}
for source, data := range conflictData {
if percentage, ok := data.Data["percentage"].(float64); ok {
if percentage > highestPercentage {
highestPercentage = percentage
highestSource = source
highestData = data.Data
}
}
}
return highestSource, highestData
}
func (h *ConflictHandler) applyResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, data map[string]interface{}) error {
ctx := context.Background()
existingProgress, err := h.db.GetReadingProgress(ctx, database.GetReadingProgressParams{
MediaItemID: mediaItemID,
UserID: userID,
})
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return err
}
percentage := 0.0
if p, ok := data["percentage"].(float64); ok {
percentage = p
}
var epubcfi pgtype.Text
if e, ok := data["epubcfi"].(string); ok {
epubcfi = pgtype.Text{String: e, Valid: true}
}
var chapter pgtype.Int4
if c, ok := data["chapter"].(float64); ok {
chapter = pgtype.Int4{Int32: int32(c), Valid: true}
}
var characterOffset pgtype.Int8
if c, ok := data["character"].(float64); ok {
characterOffset = pgtype.Int8{Int64: int64(c), Valid: true}
}
var currentPage pgtype.Int4
var totalPages pgtype.Int4
if err == nil {
currentPage = existingProgress.CurrentPage
totalPages = existingProgress.TotalPages
}
if p, ok := data["page"].(float64); ok {
currentPage = pgtype.Int4{Int32: int32(p), Valid: true}
}
if p, ok := data["total_pages"].(float64); ok {
totalPages = pgtype.Int4{Int32: int32(p), Valid: true}
}
_, err = h.db.UpdateUniversalProgress(ctx, database.UpdateUniversalProgressParams{
MediaItemID: mediaItemID,
UserID: userID,
Percentage: pgtype.Float8{Float64: percentage, Valid: true},
Epubcfi: epubcfi,
Chapter: chapter,
ChapterProgress: pgtype.Float8{Float64: percentage, Valid: true},
CharacterOffset: characterOffset,
CurrentPage: currentPage,
TotalPages: totalPages,
LastSyncDevice: pgtype.Text{String: "bulk_resolution", Valid: true},
LastSyncSource: pgtype.Text{String: "bulk", Valid: true},
ViewportY: pgtype.Float8{},
ScrollPositionX: pgtype.Float8{},
ScrollPositionY: pgtype.Float8{},
PanelNumber: pgtype.Int4{},
ReadingMode: pgtype.Text{},
ZoomLevel: pgtype.Float8{},
})
return err
}
func (h *ConflictHandler) BulkDismissConflicts(c *echo.Context) error {
user := c.Get("user").(database.Users)
var req struct {
ConflictIDs []string `json:"conflict_ids" validate:"required"`
}
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid request body")
}
if len(req.ConflictIDs) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "conflict_ids required")
}
results := make([]ConflictResult, 0, len(req.ConflictIDs))
successCount := 0
failedCount := 0
for _, conflictIDStr := range req.ConflictIDs {
conflictID, err := uuid.Parse(conflictIDStr)
if err != nil {
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
Status: "error",
Error: "invalid conflict ID",
})
failedCount++
continue
}
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID)
if err != nil {
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
Status: "error",
Error: "conflict not found",
})
failedCount++
continue
}
if conflict.UserID.Bytes != user.ID.Bytes {
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
Status: "error",
Error: "access denied",
})
failedCount++
continue
}
if err := h.db.DeleteSyncConflict(context.Background(), conflictUUID); err != nil {
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
Status: "error",
Error: "failed to dismiss",
})
failedCount++
continue
}
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
Status: "success",
})
successCount++
}
return c.JSON(http.StatusOK, BulkResolveResponse{
Results: results,
Total: len(req.ConflictIDs),
Success: successCount,
Failed: failedCount,
})
}