package handlers import ( "bookhoard/internal/database" wsync "bookhoard/internal/sync" "context" "encoding/json" "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 && 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 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 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 && 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} } 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 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.ListSyncConflictsByUser(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 && 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} } 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, }) }