refactor(conflicts): one ConflictDetail builder for the three row shapes

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).
This commit is contained in:
John O'Keefe committed 2026-10-17 16:42:37 -04:00
1 parent 58fbe372e2
commit b47a2b86a7
3 files changed
+62 -55

No files matched your search

+44 -40
View File
@@ -6,6 +6,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"log"
"net/http" "net/http"
"time" "time"
@@ -103,26 +104,10 @@ func (h *ConflictHandler) GetConflictsData(c *echo.Context) ([]ConflictDetailRes
continue continue
} }
detail := ConflictDetailResponse{ detail := NewConflictDetail(conflict.ID, conflict.MediaItemID,
ID: uuid.UUID(conflict.ID.Bytes).String(), conflict.Title, conflict.ConflictType, conflict.ResolutionStatus.String,
MediaItemID: uuid.UUID(conflict.MediaItemID.Bytes).String(), conflictData, conflict.CreatedAt, conflict.ResolvedAt, conflict.ResolvedBy,
MediaItemTitle: conflict.Title, conflict.ResolutionData)
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) response = append(response, detail)
if conflict.ResolutionStatus.String == "unresolved" { if conflict.ResolutionStatus.String == "unresolved" {
@@ -133,6 +118,41 @@ func (h *ConflictHandler) GetConflictsData(c *echo.Context) ([]ConflictDetailRes
return response, len(response), unresolvedCount, nil 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 { func (h *ConflictHandler) ListConflicts(c *echo.Context) error {
conflicts, total, unresolved, err := h.GetConflictsData(c) conflicts, total, unresolved, err := h.GetConflictsData(c)
if err != nil { if err != nil {
@@ -177,26 +197,10 @@ func (h *ConflictHandler) GetConflict(c *echo.Context) error {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to parse conflict data") return echo.NewHTTPError(http.StatusInternalServerError, "failed to parse conflict data")
} }
detail := ConflictDetailResponse{ detail := NewConflictDetail(conflict.ID, conflict.MediaItemID,
ID: uuid.UUID(conflict.ID.Bytes).String(), mediaItem.Title, conflict.ConflictType, conflict.ResolutionStatus.String,
MediaItemID: uuid.UUID(conflict.MediaItemID.Bytes).String(), conflictData, conflict.CreatedAt, conflict.ResolvedAt, conflict.ResolvedBy,
MediaItemTitle: mediaItem.Title, conflict.ResolutionData)
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) return c.JSON(http.StatusOK, detail)
} }
+13 -6
View File
@@ -217,6 +217,17 @@ func (h *DeviceHandler) CheckRegistrationStatus(c *echo.Context) error {
}) })
} }
// koreaderSyncEndpoints is the KOReader endpoint map shared by the
// surfaces that publish it (KOReader authenticates by header, so its
// URLs never embed the token).
func koreaderSyncEndpoints(baseURL string) map[string]string {
return map[string]string{
"progress": fmt.Sprintf("%s/api/sync/koreader/progress", baseURL),
"metadata": fmt.Sprintf("%s/api/sync/koreader/metadata", baseURL),
"bookmarks": fmt.Sprintf("%s/api/sync/koreader/bookmarks", baseURL),
}
}
// deviceToInfo is the single Devices->DeviceInfo mapper for every // deviceToInfo is the single Devices->DeviceInfo mapper for every
// device surface. AuthToken rides only on surfaces that deliberately // device surface. AuthToken rides only on surfaces that deliberately
// expose it (the owner's device lists). // expose it (the owner's device lists).
@@ -483,9 +494,7 @@ func (h *DeviceHandler) RegenerateDeviceToken(c *echo.Context) error {
syncURLs["bookmark"] = fmt.Sprintf("%s/api/sync/kobo/%s/bookmark", baseURL, newToken) syncURLs["bookmark"] = fmt.Sprintf("%s/api/sync/kobo/%s/bookmark", baseURL, newToken)
syncURLs["init"] = fmt.Sprintf("%s/api/sync/kobo/%s/v1/initialization", baseURL, newToken) syncURLs["init"] = fmt.Sprintf("%s/api/sync/kobo/%s/v1/initialization", baseURL, newToken)
case "koreader": case "koreader":
syncURLs["progress"] = fmt.Sprintf("%s/api/sync/koreader/progress", baseURL) syncURLs = koreaderSyncEndpoints(baseURL)
syncURLs["metadata"] = fmt.Sprintf("%s/api/sync/koreader/metadata", baseURL)
syncURLs["bookmarks"] = fmt.Sprintf("%s/api/sync/koreader/bookmarks", baseURL)
} }
// Handle HTMX requests - return HTML with page reload script // Handle HTMX requests - return HTML with page reload script
@@ -626,9 +635,7 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
syncEndpoints := map[string]string{} syncEndpoints := map[string]string{}
switch registration.DeviceType { switch registration.DeviceType {
case "koreader": case "koreader":
syncEndpoints["progress"] = fmt.Sprintf("%s/api/sync/koreader/progress", h.cfg.BaseURL) syncEndpoints = koreaderSyncEndpoints(h.cfg.BaseURL)
syncEndpoints["metadata"] = fmt.Sprintf("%s/api/sync/koreader/metadata", h.cfg.BaseURL)
syncEndpoints["bookmarks"] = fmt.Sprintf("%s/api/sync/koreader/bookmarks", h.cfg.BaseURL)
case "kobo": case "kobo":
syncEndpoints["markup"] = fmt.Sprintf("%s/api/sync/kobo/markup", h.cfg.BaseURL) syncEndpoints["markup"] = fmt.Sprintf("%s/api/sync/kobo/markup", h.cfg.BaseURL)
syncEndpoints["library"] = fmt.Sprintf("%s/api/sync/kobo/library", h.cfg.BaseURL) syncEndpoints["library"] = fmt.Sprintf("%s/api/sync/kobo/library", h.cfg.BaseURL)
+5 -9
View File
@@ -1590,15 +1590,11 @@ func registerFrontendRoutes(cfg *Config) {
if conf.ResolutionStatus.Valid && conf.ResolutionStatus.String == "unresolved" { if conf.ResolutionStatus.Valid && conf.ResolutionStatus.String == "unresolved" {
var conflictData map[string]handlers.ConflictSourceData var conflictData map[string]handlers.ConflictSourceData
if err := json.Unmarshal(conf.ConflictData, &conflictData); err == nil { if err := json.Unmarshal(conf.ConflictData, &conflictData); err == nil {
activeConflict = &handlers.ConflictDetailResponse{ detail := handlers.NewConflictDetail(conf.ID, conf.MediaItemID,
ID: uuid.UUID(conf.ID.Bytes).String(), mediaItem.Title, conf.ConflictType, conf.ResolutionStatus.String,
MediaItemID: uuid.UUID(conf.MediaItemID.Bytes).String(), conflictData, conf.CreatedAt, conf.ResolvedAt, conf.ResolvedBy,
MediaItemTitle: mediaItem.Title, conf.ResolutionData)
ConflictType: conf.ConflictType, activeConflict = &detail
ConflictData: conflictData,
ResolutionStatus: conf.ResolutionStatus.String,
CreatedAt: conf.CreatedAt.Time,
}
} }
break break
} }