package social import ( "context" "github.com/google/uuid" "pipu/api/internal/api/gen" "pipu/api/internal/platform/apperr" ) func validateActivity(a gen.ListeningActivityInput) error { if a.DeviceId == uuid.Nil || a.Sequence < 1 { return invalid() } if a.PositionMs != nil && (*a.PositionMs < 0 || *a.PositionMs > 86400000) { return invalid() } if a.Context != "other" && a.Context != "wave" && a.Context != "playlist" { return invalid() } if a.Playing && (a.TrackId == nil || *a.TrackId == uuid.Nil) { return invalid() } if a.Context == "playlist" && (a.PlaylistId == nil || *a.PlaylistId == uuid.Nil) { return invalid() } if a.Context != "playlist" && a.PlaylistId != nil { return invalid() } return nil } func (h *Handler) SetFriendsRecommendations(ctx context.Context, r gen.SetFriendsRecommendationsRequestObject) (gen.SetFriendsRecommendationsResponseObject, error) { id, e := userID(ctx) if e != nil { return nil, e } if r.Body == nil { return nil, invalid() } _, e = h.store.pool.Exec(ctx, `UPDATE users SET friends_recommendations=$2 WHERE id=$1`, id, r.Body.Enabled) return gen.SetFriendsRecommendations204Response{}, e } func (h *Handler) PublishListeningActivity(ctx context.Context, r gen.PublishListeningActivityRequestObject) (gen.PublishListeningActivityResponseObject, error) { id, e := userID(ctx) if e != nil { return nil, e } if r.Body == nil { return nil, invalid() } if e = validateActivity(*r.Body); e != nil { return nil, e } if e = h.store.publishActivity(ctx, id, *r.Body); e != nil { return nil, e } return gen.PublishListeningActivity204Response{}, nil } func (h *Handler) SetActivityVisibility(ctx context.Context, r gen.SetActivityVisibilityRequestObject) (gen.SetActivityVisibilityResponseObject, error) { id, e := userID(ctx) if e != nil { return nil, e } if r.Body == nil { return nil, invalid() } v := r.Body.Visibility if v != "nobody" && v != "friends" && v != "everyone" { return nil, invalid() } tx, e := h.store.pool.Begin(ctx) if e != nil { return nil, e } defer tx.Rollback(ctx) if _, e = tx.Exec(ctx, `UPDATE users SET activity_visibility=$2 WHERE id=$1`, id, v); e != nil { return nil, e } if v == "nobody" { if _, e = tx.Exec(ctx, `DELETE FROM listening_activity WHERE user_id=$1`, id); e != nil { return nil, e } } if e = tx.Commit(ctx); e != nil { return nil, e } return gen.SetActivityVisibility204Response{}, nil } func (h *Handler) GetActivityPlaylist(ctx context.Context, r gen.GetActivityPlaylistRequestObject) (gen.GetActivityPlaylistResponseObject, error) { id, e := userID(ctx) if e != nil { return nil, e } activity, e := h.store.activities(ctx, id, []uuid.UUID{r.UserId}) if e != nil { return nil, e } a := activity[r.UserId] if a == nil || a.PlaylistId == nil { return nil, apperr.NotFound("social.activity_unavailable", "activity unavailable") } p, e := h.playlists.ReadSocialPlaylist(ctx, *a.PlaylistId) if e != nil { return nil, e } // Recheck after projecting the playlist: a concurrent privacy change must close the view. current, e := h.store.activities(ctx, id, []uuid.UUID{r.UserId}) if e != nil { return nil, e } now := current[r.UserId] if now == nil || now.PlaylistId == nil || *now.PlaylistId != *a.PlaylistId { return nil, apperr.NotFound("social.activity_unavailable", "activity unavailable") } return gen.GetActivityPlaylist200JSONResponse(p), nil } func (s *Store) publishActivity(ctx context.Context, id uuid.UUID, a gen.ListeningActivityInput) error { tx, e := s.pool.Begin(ctx) if e != nil { return e } defer tx.Rollback(ctx) var visibility string if e = tx.QueryRow(ctx, `SELECT activity_visibility FROM users WHERE id=$1 FOR UPDATE`, id).Scan(&visibility); e != nil { return e } if visibility == "nobody" { return tx.Commit(ctx) } var track, playlist *uuid.UUID kind := "other" if a.Playing { var exists bool if e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM tracks WHERE id=$1)`, a.TrackId).Scan(&exists); e != nil { return e } if !exists { return invalid() } track = a.TrackId kind = string(a.Context) if kind == "playlist" { if e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM playlists WHERE id=$2 AND (user_id=$1 OR share_id IS NOT NULL))`, id, a.PlaylistId).Scan(&exists); e != nil { return e } if exists { playlist = a.PlaylistId } else { kind = "other" } } } position := 0 if a.PositionMs != nil { position = *a.PositionMs } _, e = tx.Exec(ctx, `INSERT INTO listening_activity(user_id,device_id,sequence,track_id,context,playlist_id,position_ms) VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT(user_id,device_id) DO UPDATE SET sequence=EXCLUDED.sequence,track_id=EXCLUDED.track_id,context=EXCLUDED.context,playlist_id=EXCLUDED.playlist_id,position_ms=EXCLUDED.position_ms,updated_at=now() WHERE EXCLUDED.sequence>listening_activity.sequence`, id, a.DeviceId, a.Sequence, track, kind, playlist, position) if e != nil { return e } if _, e = tx.Exec(ctx, `DELETE FROM listening_activity WHERE user_id=$1 AND updated_atnow()-interval '90 seconds' ORDER BY la.updated_at DESC,la.device_id LIMIT 1) a ON true JOIN tracks t ON t.id=a.track_id LEFT JOIN playlists p ON p.id=a.playlist_id AND (p.user_id=u.id OR p.share_id IS NOT NULL) WHERE u.id=ANY($2::uuid[]) AND NOT u.is_blocked AND (u.id=$1 OR u.activity_visibility='everyone' OR (u.activity_visibility='friends' AND EXISTS(SELECT 1 FROM friendships f WHERE f.low_id=LEAST($1::uuid,u.id) AND f.high_id=GREATEST($1::uuid,u.id) AND f.accepted))) AND NOT EXISTS(SELECT 1 FROM social_blocks b WHERE (b.user_id=$1 AND b.peer_id=u.id) OR (b.user_id=u.id AND b.peer_id=$1))`, viewer, ids) if e != nil { return nil, e } defer rows.Close() for rows.Next() { var id uuid.UUID var a gen.ListeningActivity if e = rows.Scan(&id, &a.TrackId, &a.TrackTitle, &a.Artist, &a.Context, &a.PlaylistId, &a.PlaylistName); e != nil { return nil, e } out[id] = &a } return out, rows.Err() } func (s *Store) attachActivity(ctx context.Context, viewer uuid.UUID, people []gen.SocialPerson) error { ids := make([]uuid.UUID, 0, len(people)) for _, p := range people { ids = append(ids, p.Id) } activities, e := s.activities(ctx, viewer, ids) if e != nil { return e } for i := range people { people[i].Activity = activities[people[i].Id] } return nil }