Files
minstrel/internal/api/events_publish.go
T
bvandeusen 3ffa5608d8 feat(#392): publish track-like + request-status events to SSE bus
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) <noreply@anthropic.com>
2026-05-12 20:55:33 -04:00

69 lines
2.0 KiB
Go

// 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),
},
})
}