fix(annotations): bookmark and note PUTs land through the sync service (LWW by id)

H3 census bugs #2 and #3: both web PUTs used plain queries that
bypassed the sync-aware layer entirely — no LWW, no source bookkeeping,
and locator fields the client couldn't even send. The highlight PUT was
the only one already routed (via HighlightID); the other two now mirror
it:

- SaveBookmarkRequest gains BookmarkID, SaveNoteRequest gains NoteID:
  id-targeted resolution reads the exact row, verifies ownership,
  LWWs under its stored dedup key, and resurrects tombstones when the
  edit is newer (same doctrine and comment structure as the highlight
  path).
- Fields the PUT omits are coalesced from the stored row before the
  full-column sync update — the legacy web PUT sends only
  title/notes/position, and zeroing cfi/percentage/page on every edit
  would have been data loss. Verified both directions by
  TestH3_BookmarkPUTPreservesLocators (omitted survives, explicit
  lands).
- The bookmark PUT's wire DTO gains the create endpoint's locator keys
  as optional fields (documented additive request keys in
  contract_pins.md); the note PUT keeps its content+position wire.

UpdateMediaBookmark/UpdateMediaNote plain queries are now uncalled
(the generated queries stay until the H4 query sweep).
This commit is contained in:
John O'Keefe committed 2026-10-17 15:53:12 -04:00
1 parent c579179fee
commit 7d698ea0d8
4 files changed
+258 -14

No files matched your search

+51 -14
View File
@@ -169,6 +169,14 @@ type UpdateMediaBookmarkRequest struct {
Title string `json:"title" validate:"required,min=1,max=255"`
Notes string `json:"notes" validate:"max=100000"`
Position string `json:"position" validate:"max=100"`
// Optional locator fields (H3): the create endpoint has always
// accepted these; the PUT ignored them until it was routed through
// the sync service. Zero-valued fields preserve the stored values.
CfiPosition string `json:"cfi_position" validate:"max=255"`
PageNumber int32 `json:"page_number"`
ChapterNumber int32 `json:"chapter_number"`
Percentage float64 `json:"percentage"`
ChapterReference int32 `json:"chapter_reference"`
}
type MediaHandler struct {
@@ -1875,6 +1883,12 @@ func (mh *MediaHandler) UpdateMediaNote(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
}
mediaID := c.Param("id")
mediaUUID, err := uuid.Parse(mediaID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
var req UpdateMediaNoteRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
@@ -1883,17 +1897,24 @@ func (mh *MediaHandler) UpdateMediaNote(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
note, err := mh.db.UpdateMediaNote(c.Request().Context(), database.UpdateMediaNoteParams{
ID: pgtype.UUID{Bytes: noteUUID, Valid: true},
Content: req.Content,
Position: pgtype.Text{String: req.Position, Valid: req.Position != ""},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
// Through the sync service (H3): the PUT lands as a proper LWW edit
// targeted by id (locator columns coalesced with the stored row), so
// source/modified_at bookkeeping is maintained — the plain query
// bypassed it entirely.
result, err := mh.annotationSvc.SaveNote(c.Request().Context(), wsync.SaveNoteRequest{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
Content: req.Content,
Position: req.Position,
Source: "web",
ModifiedAt: time.Now(),
NoteID: pgtype.UUID{Bytes: noteUUID, Valid: true},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, note)
return c.JSON(http.StatusOK, result.Note)
}
// DeleteMediaNote handles DELETE /api/media-items/:id/notes/:noteId
@@ -2178,12 +2199,16 @@ func (mh *MediaHandler) UpdateMediaBookmark(c *echo.Context) error {
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
}
bookmarkID := c.Param("bookmarkId")
bookmarkUUID, err := uuid.Parse(bookmarkID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid bookmark id"})
}
mediaID := c.Param("id")
mediaUUID, err := uuid.Parse(mediaID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
var req UpdateMediaBookmarkRequest
if err := c.Bind(&req); err != nil {
@@ -2193,18 +2218,30 @@ func (mh *MediaHandler) UpdateMediaBookmark(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
bookmark, err := mh.db.UpdateMediaBookmark(c.Request().Context(), database.UpdateMediaBookmarkParams{
ID: pgtype.UUID{Bytes: bookmarkUUID, Valid: true},
Title: req.Title,
Notes: pgtype.Text{String: req.Notes, Valid: req.Notes != ""},
Position: pgtype.Text{String: req.Position, Valid: req.Position != ""},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
// Through the sync service (H3): the PUT now lands as a proper LWW
// edit — source/modified_at bookkeeping maintained, locator fields
// carried (coalesced with the stored row for omitted ones), and the
// row targeted by id so an edited title can't miss its dedup key.
result, err := mh.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
Title: req.Title,
Position: req.Position,
Notes: req.Notes,
PageNumber: req.PageNumber,
ChapterNumber: req.ChapterNumber,
CFIPosition: req.CfiPosition,
PercentageLoc: req.Percentage,
ChapterReference: req.ChapterReference,
Source: "web",
ModifiedAt: time.Now(),
BookmarkID: pgtype.UUID{Bytes: bookmarkUUID, Valid: true},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, bookmark)
return c.JSON(http.StatusOK, result.Bookmark)
}
// DeleteMediaBookmark handles DELETE /api/media-items/:id/bookmarks/:bookmarkId