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"` 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 == "user_resolved" { 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{}{} winnerSource := req.Winner if req.Winner == "manual" { winnerData = req.ManualData winnerSource = "manual" } 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, winnerSource, winnerData); err == nil { appliedTo["progress"] = true } } if conflict.ConflictType == "annotation_highlight" || conflict.ConflictType == "annotation_bookmark" || conflict.ConflictType == "annotation_note" { if err := h.applyAnnotationResolution(conflict.MediaItemID, conflict.UserID, winnerData, conflict.ConflictType); err == nil { appliedTo["annotations"] = 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, winnerSource string, 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: winnerSource, 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) applyAnnotationResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerData map[string]interface{}, conflictType string) error { ctx := context.Background() dedupKey, _ := winnerData["dedup_key"].(string) if dedupKey == "" { return errors.New("missing dedup_key in winner data") } switch conflictType { case "annotation_highlight": return h.applyHighlightResolution(ctx, mediaItemID, userID, dedupKey, winnerData) case "annotation_bookmark": return h.applyBookmarkResolution(ctx, mediaItemID, userID, dedupKey, winnerData) case "annotation_note": return h.applyNoteResolution(ctx, mediaItemID, userID, dedupKey, winnerData) default: return errors.New("unknown annotation conflict type") } } func (h *ConflictHandler) applyHighlightResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, dedupKey string, data map[string]interface{}) error { existing, err := h.db.GetMediaHighlightByDedupKey(ctx, database.GetMediaHighlightByDedupKeyParams{ MediaItemID: mediaItemID, DedupKey: pgtype.Text{String: dedupKey, Valid: true}, }) if err != nil { return err } params := database.UpdateMediaHighlightForSyncParams{ ID: existing.ID, SelectionText: existing.SelectionText, StartPosition: existing.StartPosition, EndPosition: existing.EndPosition, Color: existing.Color, NoteText: existing.NoteText, PercentageStart: existing.PercentageStart, PercentageEnd: existing.PercentageEnd, EpubcfiStart: existing.EpubcfiStart, EpubcfiEnd: existing.EpubcfiEnd, ChapterReference: existing.ChapterReference, LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}, LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true}, DeviceSyncData: existing.DeviceSyncData, } if v, ok := data["selection_text"].(string); ok { params.SelectionText = v } if v, ok := data["color"].(string); ok { params.Color = pgtype.Text{String: v, Valid: true} } if v, ok := data["note_text"].(string); ok { params.NoteText = pgtype.Text{String: v, Valid: true} } if v, ok := data["start_position"].(string); ok { params.StartPosition = pgtype.Text{String: v, Valid: true} } if v, ok := data["end_position"].(string); ok { params.EndPosition = pgtype.Text{String: v, Valid: true} } _, err = h.db.UpdateMediaHighlightForSync(ctx, params) return err } func (h *ConflictHandler) applyBookmarkResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, dedupKey string, data map[string]interface{}) error { existing, err := h.db.GetMediaBookmarkByDedupKey(ctx, database.GetMediaBookmarkByDedupKeyParams{ MediaItemID: mediaItemID, DedupKey: pgtype.Text{String: dedupKey, Valid: true}, }) if err != nil { return err } params := database.UpdateMediaBookmarkForSyncParams{ ID: existing.ID, PageNumber: existing.PageNumber, ChapterNumber: existing.ChapterNumber, CfiPosition: existing.CfiPosition, Title: existing.Title, Position: existing.Position, Notes: existing.Notes, PercentageLocation: existing.PercentageLocation, EpubcfiLocation: existing.EpubcfiLocation, ChapterReference: existing.ChapterReference, LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}, LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true}, DeviceSyncData: existing.DeviceSyncData, } if v, ok := data["title"].(string); ok { params.Title = v } if v, ok := data["notes"].(string); ok { params.Notes = pgtype.Text{String: v, Valid: true} } _, err = h.db.UpdateMediaBookmarkForSync(ctx, params) return err } func (h *ConflictHandler) applyNoteResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, dedupKey string, data map[string]interface{}) error { existing, err := h.db.GetMediaNoteByDedupKey(ctx, database.GetMediaNoteByDedupKeyParams{ MediaItemID: mediaItemID, DedupKey: pgtype.Text{String: dedupKey, Valid: true}, }) if err != nil { return err } params := database.UpdateMediaNoteForSyncParams{ ID: existing.ID, Content: existing.Content, Position: existing.Position, PercentageLocation: existing.PercentageLocation, CharacterStart: existing.CharacterStart, CharacterEnd: existing.CharacterEnd, EpubcfiLocation: existing.EpubcfiLocation, ChapterReference: existing.ChapterReference, ParagraphReference: existing.ParagraphReference, LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}, LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true}, DeviceSyncData: existing.DeviceSyncData, } if v, ok := data["content"].(string); ok { params.Content = v } if v, ok := data["position"].(string); ok { params.Position = pgtype.Text{String: v, Valid: true} } _, err = h.db.UpdateMediaNoteForSync(ctx, params) 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, winningSource, 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, winnerSource string, 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: winnerSource, 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, }) }