From 3ffa5608d8a1e4eb21fd1259aa168c42ac5f8c15 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 12 May 2026 20:55:33 -0400 Subject: [PATCH] feat(#392): publish track-like + request-status events to SSE bus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 2 of #392 — wires the first producers onto the bus that slice 1 built. After this commit, an SSE subscriber sees real events fire: - track.liked / track.unliked when the user toggles the heart on a track (handleLikeTrack / handleUnlikeTrack). Album + artist like events intentionally deferred — they're symmetric trivial follow-ups but the operator's primary like surface is tracks. - request.status_changed when a Lidarr request is created, cancelled, approved, or rejected. Auto-approve will fire twice (pending then approved) in rapid succession, which is semantically correct; client invalidation handles that fine. Events are user-scoped via row.UserID so admin approve/reject route to the requester, not the admin acting. Helpers live in events_publish.go so the wire shape (kind names, payload keys) stays in one place — future producers in slice 3 reuse the same pattern. events_publish.go is no-op when h.eventbus is nil so tests that construct handlers without a bus continue to pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/api/admin_requests.go | 2 + internal/api/events_publish.go | 68 ++++++++++++++++++++++++++++++++++ internal/api/likes.go | 2 + internal/api/requests.go | 2 + 4 files changed, 74 insertions(+) create mode 100644 internal/api/events_publish.go diff --git a/internal/api/admin_requests.go b/internal/api/admin_requests.go index 04c6a118..69a61bdb 100644 --- a/internal/api/admin_requests.go +++ b/internal/api/admin_requests.go @@ -125,6 +125,7 @@ func (h *handlers) handleApproveRequest(w http.ResponseWriter, r *http.Request) return } + h.publishRequestStatusChanged(row) writeJSON(w, http.StatusOK, requestViewFrom(row)) } @@ -164,5 +165,6 @@ func (h *handlers) handleRejectRequest(w http.ResponseWriter, r *http.Request) { return } + h.publishRequestStatusChanged(row) writeJSON(w, http.StatusOK, requestViewFrom(row)) } diff --git a/internal/api/events_publish.go b/internal/api/events_publish.go new file mode 100644 index 00000000..80b99cb0 --- /dev/null +++ b/internal/api/events_publish.go @@ -0,0 +1,68 @@ +// Small helpers for posting live events to the SSE bus (#392). Producers +// call these from their handler success paths so the wire shape stays in +// one place. Each helper is a no-op when h.eventbus is nil (older callers +// or tests that don't construct a bus); production always has one. + +package api + +import ( + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/eventbus" + "github.com/jackc/pgx/v5/pgtype" +) + +// publishLikeEvent broadcasts a track/album/artist like or unlike. The +// event is scoped to the user (so only their own clients invalidate); the +// payload includes the entity_type and entity_id so the dispatcher can +// invalidate the right query keys. +func (h *handlers) publishLikeEvent(userID, entityID pgtype.UUID, entityType string, liked bool) { + if h.eventbus == nil { + return + } + kind := "track.liked" + if !liked { + kind = "track.unliked" + } + if entityType == "album" { + if liked { + kind = "album.liked" + } else { + kind = "album.unliked" + } + } + if entityType == "artist" { + if liked { + kind = "artist.liked" + } else { + kind = "artist.unliked" + } + } + h.eventbus.Publish(eventbus.Event{ + Kind: kind, + UserID: uuidToString(userID), + Data: map[string]any{ + "entity_type": entityType, + "entity_id": uuidToString(entityID), + }, + }) +} + +// publishRequestStatusChanged broadcasts a Lidarr request status flip to +// the request's original requester so their /requests page reflects the +// new state without manual refresh. Admin actors (approve / reject) still +// route to the requester's user_id, not the admin's — the original user +// is who needs to see the update. +func (h *handlers) publishRequestStatusChanged(row dbq.LidarrRequest) { + if h.eventbus == nil { + return + } + h.eventbus.Publish(eventbus.Event{ + Kind: "request.status_changed", + UserID: uuidToString(row.UserID), + Data: map[string]any{ + "request_id": uuidToString(row.ID), + "status": string(row.Status), + "kind": string(row.Kind), + }, + }) +} diff --git a/internal/api/likes.go b/internal/api/likes.go index 83621e7f..87ad477a 100644 --- a/internal/api/likes.go +++ b/internal/api/likes.go @@ -60,6 +60,7 @@ func (h *handlers) handleLikeTrack(w http.ResponseWriter, r *http.Request) { _ = playevents.CaptureContextualLikeIfPlaying(r.Context(), q, user.ID, id, h.logger) } h.logLikeChange(r, syncpkg.EntityLikeTrack, user.ID, id, syncpkg.OpUpsert) + h.publishLikeEvent(user.ID, id, "track", true) w.WriteHeader(http.StatusNoContent) } @@ -83,6 +84,7 @@ func (h *handlers) handleUnlikeTrack(w http.ResponseWriter, r *http.Request) { // Don't fail the response — soft-delete is best-effort. } h.logLikeChange(r, syncpkg.EntityLikeTrack, user.ID, id, syncpkg.OpDelete) + h.publishLikeEvent(user.ID, id, "track", false) w.WriteHeader(http.StatusNoContent) } diff --git a/internal/api/requests.go b/internal/api/requests.go index 787ae252..fcd84b95 100644 --- a/internal/api/requests.go +++ b/internal/api/requests.go @@ -175,6 +175,7 @@ func (h *handlers) handleCreateRequest(w http.ResponseWriter, r *http.Request) { } } + h.publishRequestStatusChanged(row) writeJSON(w, http.StatusCreated, requestViewFrom(row)) } @@ -254,5 +255,6 @@ func (h *handlers) handleCancelRequest(w http.ResponseWriter, r *http.Request) { return } + h.publishRequestStatusChanged(row) writeJSON(w, http.StatusOK, requestViewFrom(row)) }