ListConflicts, GetConflict, and the SSR media-detail active-conflict lookup each hand-mapped their sqlc row (list-by-user, get-by-id, list-by-media-item) into ConflictDetailResponse, triplicating the optional resolution tail — including the silently-swallowed ResolutionData unmarshal error, three times. NewConflictDetail is the single mapper; a malformed resolution blob now logs instead of vanishing. Also extracts koreaderSyncEndpoints (the KOReader URL map duplicated verbatim between token rotation and device approval; kobo's two URL sets stay local because they deliberately differ). Verified in the test-runner container (host conflict tests fail environmentally on the container-only uploads path — baseline behavior).
865 lines
27 KiB
Go
865 lines
27 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
wsync "bookhoard/internal/sync"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log"
|
|
"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
|
|
progressSvc *wsync.ProgressService
|
|
}
|
|
|
|
// NewConflictHandler requires the shared ProgressService: applying a
|
|
// resolution is a progress WRITE and must funnel through SaveProgress so
|
|
// the winner's snapshot lands without erasing row fields it doesn't carry
|
|
// (audio_offset_seconds survives the write).
|
|
func NewConflictHandler(db *database.Queries, connManager *wsync.ConnectionManager, progressSvc *wsync.ProgressService) *ConflictHandler {
|
|
return &ConflictHandler{
|
|
db: db,
|
|
connManager: connManager,
|
|
progressSvc: progressSvc,
|
|
}
|
|
}
|
|
|
|
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 := NewConflictDetail(conflict.ID, conflict.MediaItemID,
|
|
conflict.Title, conflict.ConflictType, conflict.ResolutionStatus.String,
|
|
conflictData, conflict.CreatedAt, conflict.ResolvedAt, conflict.ResolvedBy,
|
|
conflict.ResolutionData)
|
|
|
|
response = append(response, detail)
|
|
if conflict.ResolutionStatus.String == "unresolved" {
|
|
unresolvedCount++
|
|
}
|
|
}
|
|
|
|
return response, len(response), unresolvedCount, nil
|
|
}
|
|
|
|
// NewConflictDetail maps any sync-conflict row shape (list-by-user,
|
|
// get-by-id, list-by-media-item) to the wire detail. The resolution
|
|
// tail is conditional exactly as before; a malformed resolution blob
|
|
// now logs instead of vanishing silently.
|
|
func NewConflictDetail(
|
|
id, mediaItemID pgtype.UUID,
|
|
title, conflictType, resolutionStatus string,
|
|
conflictData map[string]ConflictSourceData,
|
|
createdAt, resolvedAt pgtype.Timestamptz,
|
|
resolvedBy pgtype.UUID,
|
|
resolutionData []byte,
|
|
) ConflictDetailResponse {
|
|
detail := ConflictDetailResponse{
|
|
ID: uuid.UUID(id.Bytes).String(),
|
|
MediaItemID: uuid.UUID(mediaItemID.Bytes).String(),
|
|
MediaItemTitle: title,
|
|
ConflictType: conflictType,
|
|
ConflictData: conflictData,
|
|
ResolutionStatus: resolutionStatus,
|
|
CreatedAt: createdAt.Time,
|
|
}
|
|
if resolvedBy.Valid {
|
|
detail.ResolvedBy = uuid.UUID(resolvedBy.Bytes).String()
|
|
}
|
|
if resolvedAt.Valid {
|
|
detail.ResolvedAt = &resolvedAt.Time
|
|
}
|
|
if resolutionData != nil {
|
|
if err := json.Unmarshal(resolutionData, &detail.ResolutionData); err != nil {
|
|
log.Printf("conflict %s: unparsable resolution data: %v", detail.ID, err)
|
|
}
|
|
}
|
|
return detail
|
|
}
|
|
|
|
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 := NewConflictDetail(conflict.ID, conflict.MediaItemID,
|
|
mediaItem.Title, conflict.ConflictType, conflict.ResolutionStatus.String,
|
|
conflictData, conflict.CreatedAt, conflict.ResolvedAt, conflict.ResolvedBy,
|
|
conflict.ResolutionData)
|
|
|
|
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)
|
|
}
|
|
|
|
// applyProgressResolution writes the winning snapshot through the
|
|
// SaveProgress funnel. Deliberate choices (hardening H1):
|
|
// - Source "conflict_resolution": the row's last-write attribution; the
|
|
// winner's identity is durably recorded on the conflict row
|
|
// (resolution_data.winner).
|
|
// - SkipConflictDetection: both flows apply BEFORE the conflict row is
|
|
// marked resolved, so the funnel's LWW detector would otherwise file a
|
|
// duplicate pending conflict on the item just resolved
|
|
// (HasRecentConflictResolution is false at apply time).
|
|
// - Broadcast: live devices jump to the winning position, same as a
|
|
// device push; the write also captures one reading_history session
|
|
// (idle-gap ceiling applies) — accepted as the resolution's own event.
|
|
// - Every field the winner's snapshot doesn't carry (audio_offset_seconds,
|
|
// viewports, zoom, reading_mode, and an absent percentage) SURVIVES via
|
|
// the service's LWW seed instead of being NULLed — that is the fix.
|
|
// chapter_progress keeps its stored value instead of being overwritten
|
|
// with the book-level percentage.
|
|
func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerSource string, data map[string]interface{}) error {
|
|
req := wsync.SaveProgressRequest{
|
|
MediaItemID: mediaItemID,
|
|
UserID: userID,
|
|
Source: "conflict_resolution",
|
|
SkipConflictDetection: true,
|
|
Broadcast: true,
|
|
// A winner without a CFI (percentage-only sources, e.g. Kobo)
|
|
// declares no locator: clear the inherited anchor pair instead of
|
|
// re-pairing the new percentage with the loser's stale CFI/
|
|
// context. A winner WITH a CFI keeps the stored pair coherent.
|
|
ClearLocator: true,
|
|
}
|
|
if p, ok := data["percentage"].(float64); ok {
|
|
req.Percentage = &p
|
|
}
|
|
if e, ok := data["epubcfi"].(string); ok {
|
|
req.Epubcfi = &e
|
|
req.ClearLocator = false
|
|
}
|
|
if ct, ok := data["context_text"].(string); ok {
|
|
req.ContextText = &ct
|
|
}
|
|
if c, ok := data["chapter"].(float64); ok {
|
|
ch := int(c)
|
|
req.Chapter = &ch
|
|
}
|
|
if c, ok := data["character"].(float64); ok {
|
|
co := int64(c)
|
|
req.CharacterOffset = &co
|
|
}
|
|
if p, ok := data["page"].(float64); ok {
|
|
pg := int(p)
|
|
req.CurrentPage = &pg
|
|
}
|
|
if p, ok := data["total_pages"].(float64); ok {
|
|
tp := int(p)
|
|
req.TotalPages = &tp
|
|
}
|
|
|
|
_, err := h.progressSvc.SaveProgress(context.Background(), req)
|
|
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,
|
|
UserID: userID,
|
|
}
|
|
|
|
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,
|
|
UserID: userID,
|
|
}
|
|
|
|
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,
|
|
UserID: userID,
|
|
}
|
|
|
|
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.applyProgressResolution(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) 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,
|
|
})
|
|
}
|