feat(api): capture contextual_likes on like during open play_event
Two new helpers in internal/playevents (CaptureContextualLikeIfPlaying, SoftDeleteContextualLikes) called by both api and subsonic surfaces. Like inserts a new contextual_likes row when LikeTrack actually inserted (rows=1) AND there's an open play_event with a vector. Unlike soft-deletes via deleted_at. Idempotent in both directions. Subsonic wiring lands in the next commit.
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
package playevents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// CaptureContextualLikeIfPlaying snapshots the user's currently-playing
|
||||
// session context into a new contextual_likes row. No-op if no open
|
||||
// play_event exists or its session_vector is NULL. Failures are logged
|
||||
// but never returned to the caller — contextual capture is best-effort
|
||||
// and must not break the like response.
|
||||
func CaptureContextualLikeIfPlaying(
|
||||
ctx context.Context,
|
||||
q *dbq.Queries,
|
||||
userID, trackID pgtype.UUID,
|
||||
logger *slog.Logger,
|
||||
) error {
|
||||
event, err := q.GetOpenPlayEventForUser(ctx, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil // no play in progress; nothing to capture
|
||||
}
|
||||
logger.Error("playevents: get open play_event", "err", err)
|
||||
return nil // best-effort
|
||||
}
|
||||
if event.SessionVectorAtPlay == nil {
|
||||
return nil // event predates this slice; no vector to snapshot
|
||||
}
|
||||
if err := q.InsertContextualLike(ctx, dbq.InsertContextualLikeParams{
|
||||
UserID: userID,
|
||||
TrackID: trackID,
|
||||
SessionVector: event.SessionVectorAtPlay,
|
||||
SessionID: event.SessionID,
|
||||
}); err != nil {
|
||||
logger.Error("playevents: insert contextual_like", "err", err)
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SoftDeleteContextualLikes marks all currently-active contextual_likes
|
||||
// rows for (user, track) as deleted. Idempotent.
|
||||
func SoftDeleteContextualLikes(
|
||||
ctx context.Context,
|
||||
q *dbq.Queries,
|
||||
userID, trackID pgtype.UUID,
|
||||
) error {
|
||||
return q.SoftDeleteContextualLikesForUserTrack(ctx, dbq.SoftDeleteContextualLikesForUserTrackParams{
|
||||
UserID: userID,
|
||||
TrackID: trackID,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package playevents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
func TestCaptureContextualLikeIfPlaying_NoOpenEvent_NoOp(t *testing.T) {
|
||||
f := newFixture(t, 200_000)
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
q := dbq.New(f.pool)
|
||||
if err := CaptureContextualLikeIfPlaying(context.Background(), q, f.user, f.track, logger); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
var count int
|
||||
_ = f.pool.QueryRow(context.Background(), "SELECT count(*) FROM contextual_likes WHERE user_id=$1", f.user).Scan(&count)
|
||||
if count != 0 {
|
||||
t.Errorf("count = %d, want 0 (no open play_event)", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureContextualLikeIfPlaying_OpenEventWithVector_Inserts(t *testing.T) {
|
||||
f := newFixture(t, 200_000)
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
// Start a play (creates an open play_event with a populated vector).
|
||||
at := time.Now().UTC()
|
||||
_, err := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", at)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordPlayStarted: %v", err)
|
||||
}
|
||||
// Like the same track — captures contextual signal.
|
||||
q := dbq.New(f.pool)
|
||||
if err := CaptureContextualLikeIfPlaying(context.Background(), q, f.user, f.track, logger); err != nil {
|
||||
t.Fatalf("Capture: %v", err)
|
||||
}
|
||||
var rawVec []byte
|
||||
if err := f.pool.QueryRow(context.Background(),
|
||||
"SELECT session_vector FROM contextual_likes WHERE user_id=$1 AND track_id=$2",
|
||||
f.user, f.track).Scan(&rawVec); err != nil {
|
||||
t.Fatalf("query: %v", err)
|
||||
}
|
||||
var vec map[string]any
|
||||
_ = json.Unmarshal(rawVec, &vec)
|
||||
if vec == nil {
|
||||
t.Errorf("vector empty: %s", rawVec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSoftDeleteContextualLikes_MarksActiveRowsDeleted(t *testing.T) {
|
||||
f := newFixture(t, 200_000)
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
at := time.Now().UTC()
|
||||
_, _ = f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", at)
|
||||
q := dbq.New(f.pool)
|
||||
_ = CaptureContextualLikeIfPlaying(context.Background(), q, f.user, f.track, logger)
|
||||
// Now soft-delete.
|
||||
if err := SoftDeleteContextualLikes(context.Background(), q, f.user, f.track); err != nil {
|
||||
t.Fatalf("SoftDelete: %v", err)
|
||||
}
|
||||
var deletedAt pgtype.Timestamptz
|
||||
if err := f.pool.QueryRow(context.Background(),
|
||||
"SELECT deleted_at FROM contextual_likes WHERE user_id=$1 AND track_id=$2",
|
||||
f.user, f.track).Scan(&deletedAt); err != nil {
|
||||
t.Fatalf("query: %v", err)
|
||||
}
|
||||
if !deletedAt.Valid {
|
||||
t.Errorf("deleted_at not set after SoftDelete")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user