diff --git a/api/dbv1/access.go b/api/dbv1/access.go index 6dd8109a..6f667ec1 100644 --- a/api/dbv1/access.go +++ b/api/dbv1/access.go @@ -323,7 +323,7 @@ func (q *Queries) GetBulkTrackAccess( var mint string var balance int64 if err := rows.Scan(&mint, &balance); err == nil { - walletTokenBalances[mint] = balance + walletTokenBalances[mint] += balance } } return rows.Err() diff --git a/api/dbv1/full_comments.go b/api/dbv1/full_comments.go index e8a14ee2..2068d0c0 100644 --- a/api/dbv1/full_comments.go +++ b/api/dbv1/full_comments.go @@ -40,7 +40,6 @@ type FullComment struct { Replies []FullComment `json:"replies"` ParentCommentId pgtype.Int4 `json:"parent_comment_id"` - // this should be omitted ReplyIds []int32 `db:"reply_ids" json:"-"` } @@ -51,12 +50,12 @@ func (q *Queries) FullCommentsKeyed(ctx context.Context, arg GetCommentsParams) sql := ` SELECT - comment_id as id, - parent_comment_id, - entity_type, - entity_id, - user_id, - text as message, + comments.comment_id AS id, + comment_threads.parent_comment_id, + comments.entity_type, + comments.entity_id, + comments.user_id, + comments.text AS message, ( SELECT json_agg( @@ -72,7 +71,7 @@ func (q *Queries) FullCommentsKeyed(ctx context.Context, arg GetCommentsParams) ) m )::jsonb as mentions, - track_timestamp_s, + comments.track_timestamp_s, ( SELECT count(*) @@ -90,7 +89,7 @@ func (q *Queries) FullCommentsKeyed(ctx context.Context, arg GetCommentsParams) AND cc.is_delete = false ) as reply_ids, - is_edited, + comments.is_edited, EXISTS ( SELECT 1 @@ -104,7 +103,7 @@ func (q *Queries) FullCommentsKeyed(ctx context.Context, arg GetCommentsParams) SELECT 1 FROM comment_reactions WHERE comment_id = comments.comment_id - AND user_id = tracks.owner_id + AND user_id = COALESCE(tracks.owner_id, comments.entity_id) AND is_delete = false ) AS is_artist_reacted, @@ -115,8 +114,8 @@ func (q *Queries) FullCommentsKeyed(ctx context.Context, arg GetCommentsParams) FROM comment_notification_settings mutes WHERE @my_id > 0 AND mutes.user_id = @my_id - AND mutes.entity_type = entity_type - AND mutes.entity_id = entity_id + AND mutes.entity_type = comments.entity_type + AND mutes.entity_id = comments.entity_id LIMIT 1 ), false) as is_muted, @@ -124,10 +123,13 @@ func (q *Queries) FullCommentsKeyed(ctx context.Context, arg GetCommentsParams) comments.updated_at FROM comments - JOIN tracks ON entity_id = track_id + LEFT JOIN tracks ON comments.entity_type = 'Track' AND comments.entity_id = tracks.track_id LEFT JOIN comment_threads USING (comment_id) - WHERE comment_id = ANY(@ids::int[]) - AND (@include_unlisted = true OR tracks.is_unlisted = false) + WHERE comments.comment_id = ANY(@ids::int[]) + AND ( + (comments.entity_type = 'Track' AND (@include_unlisted = true OR COALESCE(tracks.is_unlisted, false) = false)) + OR comments.entity_type = 'FanClub' + ) ORDER BY comments.created_at DESC ` @@ -150,7 +152,6 @@ func (q *Queries) FullCommentsKeyed(ctx context.Context, arg GetCommentsParams) commentMap[int32(comment.Id)] = comment } - // fetch replies replyIds := []int32{} for _, comment := range comments { replyIds = append(replyIds, comment.ReplyIds...) @@ -170,7 +171,6 @@ func (q *Queries) FullCommentsKeyed(ctx context.Context, arg GetCommentsParams) comment.Replies = append(comment.Replies, reply) } } - // todo: sort replies? comment.ReplyCount = len(comment.Replies) if comment.IsDelete { diff --git a/api/dbv1/parallel.go b/api/dbv1/parallel.go index 69efd789..8de1a37f 100644 --- a/api/dbv1/parallel.go +++ b/api/dbv1/parallel.go @@ -7,11 +7,11 @@ import ( ) type ParallelParams struct { - UserIds []int32 - TrackIds []int32 - PlaylistIds []int32 - MyID int32 - AuthedWallet string + UserIds []int32 + TrackIds []int32 + PlaylistIds []int32 + MyID int32 + AuthedWallet string } type ParallelResult struct { diff --git a/api/server.go b/api/server.go index e6bf1f4f..0757338f 100644 --- a/api/server.go +++ b/api/server.go @@ -507,6 +507,9 @@ func NewApiServer(config config.Config) *ApiServer { g.Post("/tracks/:trackId/downloads", app.requireAuthMiddleware, app.requireWriteScope, app.postV1TrackDownload) g.Put("/tracks/:trackId", app.requireAuthMiddleware, app.requireWriteScope, app.putV1Track) g.Delete("/tracks/:trackId", app.requireAuthMiddleware, app.requireWriteScope, app.deleteV1Track) + g.Get("/fan_club/feed", app.v1FanClubFeed) + g.Get("/fan-club/feed", app.v1FanClubFeed) + g.Get("/tracks/:trackId/comments", app.v1TrackComments) g.Get("/tracks/:trackId/comment_count", app.v1TrackCommentCount) g.Get("/tracks/:trackId/comment-count", app.v1TrackCommentCount) diff --git a/api/swagger/swagger-v1.yaml b/api/swagger/swagger-v1.yaml index 716d05b0..55160766 100644 --- a/api/swagger/swagger-v1.yaml +++ b/api/swagger/swagger-v1.yaml @@ -611,6 +611,100 @@ paths: "500": description: Server error content: {} + /fan_club/feed: + get: + tags: + - fan_club + description: Get the fan club feed for a given artist, including text posts and comments + operationId: Get Fan Club Feed + security: + - {} + - OAuth2: + - read + parameters: + - name: mint + in: query + description: The mint address of the artist's fan club token + required: true + schema: + type: string + - name: user_id + in: query + description: The user ID of the user making the request + schema: + type: string + - name: offset + in: query + description: + The number of items to skip. Useful for pagination (page number + * limit) + schema: + type: integer + - name: limit + in: query + description: The number of items to fetch + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/track_comments_response" + "400": + description: Bad request + content: {} + "500": + description: Server error + content: {} + /fan-club/feed: + get: + tags: + - fan_club + description: Get the fan club feed for a given artist (hyphenated alias), including text posts and comments + operationId: Get Fan Club Feed Alias + security: + - {} + - OAuth2: + - read + parameters: + - name: mint + in: query + description: The mint address of the artist's fan club token + required: true + schema: + type: string + - name: user_id + in: query + description: The user ID of the user making the request + schema: + type: string + - name: offset + in: query + description: + The number of items to skip. Useful for pagination (page number + * limit) + schema: + type: integer + - name: limit + in: query + description: The number of items to fetch + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/track_comments_response" + "400": + description: Bad request + content: {} + "500": + description: Server error + content: {} /developer-apps: post: tags: @@ -14682,6 +14776,7 @@ components: description: Type of entity that can be commented on enum: - Track + - Coin top_listener: type: object properties: diff --git a/api/v1_fan_club_feed.go b/api/v1_fan_club_feed.go new file mode 100644 index 00000000..a4f66d5f --- /dev/null +++ b/api/v1_fan_club_feed.go @@ -0,0 +1,400 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "time" + + "api.audius.co/api/dbv1" + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" +) + +// fanClubFeedMergedSQL returns rows: kind ('text_post' | 'track'), comment_id, track_id, created_at. +const fanClubFeedMergedSQL = ` +WITH +coin AS ( + SELECT mint, user_id AS owner_id + FROM artist_coins + WHERE mint = @mint +), + +muted_by_karma AS ( + SELECT muted_user_id + FROM muted_users + JOIN aggregate_user ON muted_users.user_id = aggregate_user.user_id + WHERE muted_users.is_delete = false + GROUP BY muted_user_id + HAVING SUM(aggregate_user.follower_count) >= @karmaCommentCountThreshold +), + +low_abuse_score AS ( + SELECT user_id + FROM aggregate_user + WHERE score < 0 +), + +deactivated_users AS ( + SELECT user_id + FROM users + WHERE is_deactivated = true +), + +high_karma_reporters AS ( + SELECT comment_reports.comment_id + FROM comment_reports + JOIN aggregate_user ON comment_reports.user_id = aggregate_user.user_id + WHERE comment_reports.is_delete = false + GROUP BY comment_reports.comment_id + HAVING SUM(aggregate_user.follower_count) >= @karmaCommentCountThreshold +), + +eligible_comments AS ( + SELECT + 'text_post'::text AS kind, + comments.comment_id AS comment_id, + NULL::integer AS track_id, + comments.created_at + FROM comments + LEFT JOIN coin ON comments.entity_id = coin.owner_id + LEFT JOIN comment_threads USING (comment_id) + LEFT JOIN comment_reports ON comments.comment_id = comment_reports.comment_id + LEFT JOIN muted_users ON ( + muted_users.muted_user_id = comments.user_id + AND ( + muted_users.user_id = @myId + OR muted_users.user_id = coin.owner_id + OR muted_users.muted_user_id IN (SELECT muted_user_id FROM muted_by_karma) + ) + AND @myId != comments.user_id + ) + LEFT JOIN low_abuse_score ON ( + low_abuse_score.user_id = comments.user_id + AND @myId != comments.user_id + AND coin.owner_id != comments.user_id + ) + LEFT JOIN deactivated_users ON ( + deactivated_users.user_id = comments.user_id + AND @myId != comments.user_id + AND coin.owner_id != comments.user_id + ) + WHERE comments.entity_id = coin.owner_id + AND NOT EXISTS ( + SELECT 1 FROM comment_threads ct WHERE ct.comment_id = comments.comment_id + ) + AND comments.entity_type = 'FanClub' + AND comments.is_delete = false + AND ( + comment_reports.comment_id IS NULL + OR ( + comment_reports.user_id != COALESCE(@myId, 0) + AND comment_reports.user_id != coin.owner_id + AND comments.comment_id NOT IN (SELECT hkr.comment_id FROM high_karma_reporters hkr) + ) + OR comment_reports.is_delete = true + ) + AND ( + muted_users.muted_user_id IS NULL + OR muted_users.is_delete = true + ) + AND low_abuse_score.user_id IS NULL + AND deactivated_users.user_id IS NULL +), + +eligible_tracks AS ( + SELECT + 'track'::text AS kind, + NULL::integer AS comment_id, + tracks.track_id AS track_id, + tracks.created_at + FROM tracks + INNER JOIN coin ON tracks.owner_id = coin.owner_id + WHERE tracks.is_current = true + AND tracks.is_delete = false + AND tracks.is_unlisted = false + AND tracks.is_stream_gated = true + AND tracks.stream_conditions->'token_gate'->>'token_mint' = @mint +) + +SELECT kind, comment_id, track_id, created_at FROM ( + SELECT * FROM eligible_comments + UNION ALL + SELECT * FROM eligible_tracks +) AS merged +` + +// viewerCanRevealFanClubText is true for the artist, Audius users holding >= 1 whole token, +// or a verified third-party Solana wallet with enough raw balance for that mint. +func (app *ApiServer) viewerCanRevealFanClubText( + ctx context.Context, + artistUserID int32, + mint string, + myID int32, + solanaWallet string, +) (bool, error) { + if myID != 0 && myID == artistUserID { + return true, nil + } + + var audiusOK bool + err := app.pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 + FROM sol_user_balances sub + JOIN artist_coins ac ON ac.mint = sub.mint + WHERE sub.user_id = $1 + AND ac.user_id = $2 + AND ac.mint = $3 + AND sub.balance >= power(10::numeric, ac.decimals)::bigint + ) + `, myID, artistUserID, mint).Scan(&audiusOK) + if err != nil { + return false, err + } + if audiusOK { + return true, nil + } + + if solanaWallet == "" { + return false, nil + } + + var walletOK bool + err = app.pool.QueryRow(ctx, ` + SELECT COALESCE(SUM(st.balance), 0) >= power(10::numeric, ac.decimals)::bigint + FROM artist_coins ac + LEFT JOIN sol_token_account_balances st ON st.owner = $2 AND st.mint = ac.mint + WHERE ac.mint = $1 + GROUP BY ac.decimals + `, mint, solanaWallet).Scan(&walletOK) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + return false, err + } + return walletOK, nil +} + +func fanClubRedactCommentMessageMap(m map[string]any) { + m["message"] = nil + reps, ok := m["replies"].([]any) + if !ok || reps == nil { + return + } + for _, r := range reps { + rm, ok := r.(map[string]any) + if !ok { + continue + } + fanClubRedactCommentMessageMap(rm) + } +} + +func fanClubCommentForFeedJSON(co dbv1.FullComment, reveal bool) (any, error) { + if reveal { + return co, nil + } + b, err := json.Marshal(co) + if err != nil { + return nil, err + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + return nil, err + } + fanClubRedactCommentMessageMap(m) + return m, nil +} + +func (app *ApiServer) v1FanClubFeed(c *fiber.Ctx) error { + mint := c.Query("mint") + if mint == "" { + return fiber.NewError(fiber.StatusBadRequest, "mint query parameter is required") + } + + var artistUserID int32 + err := app.pool.QueryRow(c.Context(), ` + SELECT user_id FROM artist_coins WHERE mint = $1 LIMIT 1 + `, mint).Scan(&artistUserID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return fiber.NewError(fiber.StatusNotFound, "unknown artist coin mint") + } + return err + } + + var params GetCommentsParams + if err := app.ParseAndValidateQueryParams(c, ¶ms); err != nil { + return err + } + + myID := app.getMyId(c) + solWallet, _ := c.Context().Value(SolanaWalletCtxKey).(string) + + revealText, err := app.viewerCanRevealFanClubText(c.Context(), artistUserID, mint, myID, solWallet) + if err != nil { + return err + } + + args := pgx.NamedArgs{ + "myId": myID, + "mint": mint, + "karmaCommentCountThreshold": karmaCommentCountThreshold, + "limit": params.Limit, + "offset": params.Offset, + } + + orderBy := `merged.created_at DESC` + switch params.SortMethod { + case "timestamp": + orderBy = `merged.created_at ASC` + case "top": + orderBy = `(SELECT COUNT(*) FROM comment_reactions cr WHERE cr.comment_id = merged.comment_id) DESC NULLS LAST, merged.created_at DESC` + } + + sql := fanClubFeedMergedSQL + ` ORDER BY ` + orderBy + ` +LIMIT @limit +OFFSET @offset +` + + type feedRow struct { + Kind string + CommentID *int32 + TrackID *int32 + CreatedAt time.Time + } + + rows, err := app.pool.Query(c.Context(), sql, args) + if err != nil { + return err + } + defer rows.Close() + + var feedRows []feedRow + for rows.Next() { + var r feedRow + if err := rows.Scan(&r.Kind, &r.CommentID, &r.TrackID, &r.CreatedAt); err != nil { + return err + } + feedRows = append(feedRows, r) + } + if err := rows.Err(); err != nil { + return err + } + + commentIDs := make([]int32, 0) + trackIDs := make([]int32, 0) + for _, r := range feedRows { + switch r.Kind { + case "text_post": + if r.CommentID != nil { + commentIDs = append(commentIDs, *r.CommentID) + } + case "track": + if r.TrackID != nil { + trackIDs = append(trackIDs, *r.TrackID) + } + } + } + + comments, err := app.queries.FullComments(c.Context(), dbv1.GetCommentsParams{ + Ids: commentIDs, + MyID: myID, + IncludeUnlisted: true, + }) + if err != nil { + return err + } + commentByID := make(map[int32]dbv1.FullComment, len(comments)) + for _, co := range comments { + commentByID[int32(co.Id)] = co + } + + parallel, err := app.queries.Parallel(c.Context(), dbv1.ParallelParams{ + UserIds: nil, + TrackIds: trackIDs, + MyID: myID, + AuthedWallet: app.tryGetAuthedWallet(c), + }) + if err != nil { + return err + } + trackMap := parallel.TrackMap + if trackMap == nil { + trackMap = map[int32]dbv1.Track{} + } + + userIDs := []int32{} + relatedTrackIDs := append([]int32(nil), trackIDs...) + for _, co := range comments { + userIDs = append(userIDs, int32(co.UserId)) + if co.EntityType == "Track" { + relatedTrackIDs = append(relatedTrackIDs, int32(co.EntityId)) + } + if co.EntityType == "FanClub" { + userIDs = append(userIDs, int32(co.EntityId)) + } + for _, m := range co.Mentions { + userIDs = append(userIDs, int32(m.UserId)) + } + } + for _, tid := range trackIDs { + if t, ok := trackMap[tid]; ok { + userIDs = append(userIDs, int32(t.UserID)) + } + } + + related, err := app.queries.Parallel(c.Context(), dbv1.ParallelParams{ + UserIds: userIDs, + TrackIds: relatedTrackIDs, + MyID: myID, + AuthedWallet: app.tryGetAuthedWallet(c), + }) + if err != nil { + return err + } + + items := make([]fiber.Map, 0, len(feedRows)) + for _, r := range feedRows { + switch r.Kind { + case "text_post": + if r.CommentID == nil { + continue + } + co, ok := commentByID[*r.CommentID] + if !ok { + continue + } + commentJSON, err := fanClubCommentForFeedJSON(co, revealText) + if err != nil { + return err + } + items = append(items, fiber.Map{ + "item_type": "text_post", + "comment": commentJSON, + }) + case "track": + if r.TrackID == nil { + continue + } + tr, ok := trackMap[*r.TrackID] + if !ok { + continue + } + items = append(items, fiber.Map{ + "item_type": "track", + "track": tr, + }) + } + } + + return c.JSON(fiber.Map{ + "data": items, + "related": fiber.Map{ + "users": related.UserList(), + "tracks": related.TrackList(), + }, + }) +} diff --git a/api/v1_fan_club_feed_test.go b/api/v1_fan_club_feed_test.go new file mode 100644 index 00000000..53126057 --- /dev/null +++ b/api/v1_fan_club_feed_test.go @@ -0,0 +1,396 @@ +package api + +import ( + "crypto/ed25519" + "io" + "net/http/httptest" + "net/url" + "testing" + + "api.audius.co/database" + "api.audius.co/trashid" + "github.com/mr-tron/base58" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testFanClubFeedMint = "FanClubMintTest11111111111111111111111" + +func testFanClubFeedBaseUsers() []map[string]any { + return []map[string]any{ + { + "user_id": 1, + "handle": "fcartist", + "handle_lc": "fcartist", + "name": "FC Artist", + "wallet": "0x7d273271690538cf855e5b3002a0dd8c154bb060", + }, + { + "user_id": 2, + "handle": "fcfan", + "handle_lc": "fcfan", + "name": "FC Fan", + "wallet": "0xc3d1d41e6872ffbd15c473d14fc3a9250be5b5e0", + }, + } +} + +func testFanClubFeedURL(viewerUserID int, extraQuery string) string { + v := url.Values{} + v.Set("mint", testFanClubFeedMint) + if viewerUserID > 0 { + v.Set("user_id", trashid.MustEncodeHashID(viewerUserID)) + } + if extraQuery != "" { + more, err := url.ParseQuery(extraQuery) + if err != nil { + panic(err) + } + for k, vals := range more { + for _, val := range vals { + v.Add(k, val) + } + } + } + return "/v1/fan_club/feed?" + v.Encode() +} + +func testGetWithSolanaHeaders(t *testing.T, app *ApiServer, path string, walletPub58, message string, sig []byte) (int, []byte) { + t.Helper() + req := httptest.NewRequest("GET", path, nil) + req.Header.Set("X-Solana-Wallet", walletPub58) + req.Header.Set("X-Solana-Message", message) + req.Header.Set("X-Solana-Signature", base58.Encode(sig)) + res, err := app.Test(req, -1) + require.NoError(t, err) + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + return res.StatusCode, body +} + +func TestFanClubFeed_MissingMint(t *testing.T) { + app := emptyTestApp(t) + status, _ := testGet(t, app, "/v1/fan_club/feed") + assert.Equal(t, 400, status) +} + +func TestFanClubFeed_UnknownMint(t *testing.T) { + app := emptyTestApp(t) + database.Seed(app.pool.Replicas[0], database.FixtureMap{ + "users": testFanClubFeedBaseUsers(), + }) + status, _ := testGet(t, app, "/v1/fan_club/feed?mint=UnknownMintZZZZZZZZZZZZZZZZZZZZZZZZZZZ") + assert.Equal(t, 404, status) +} + +func TestFanClubFeed_UnauthedStillReturnsFeed(t *testing.T) { + app := emptyTestApp(t) + database.Seed(app.pool.Replicas[0], database.FixtureMap{ + "users": testFanClubFeedBaseUsers(), + "artist_coins": { + { + "user_id": 1, + "mint": testFanClubFeedMint, + "decimals": 6, + "ticker": "FCT", + }, + }, + }) + status, _ := testGet(t, app, testFanClubFeedURL(0, "")) + assert.Equal(t, 200, status) +} + +func TestFanClubFeed_ArtistSeesOnlyCoinGatedTrack(t *testing.T) { + app := emptyTestApp(t) + database.Seed(app.pool.Replicas[0], database.FixtureMap{ + "users": testFanClubFeedBaseUsers(), + "artist_coins": { + { + "user_id": 1, + "mint": testFanClubFeedMint, + "decimals": 6, + "ticker": "FCT", + }, + }, + "tracks": { + { + "track_id": 701, + "owner_id": 1, + "title": "Coin gated for feed", + "is_stream_gated": true, + "stream_conditions": map[string]any{ + "token_gate": map[string]any{ + "token_mint": testFanClubFeedMint, + "token_amount": 1, + }, + }, + "created_at": "2020-01-02 00:00:00", + }, + { + "track_id": 702, + "owner_id": 1, + "title": "Public not gated", + "is_stream_gated": false, + "created_at": "2020-01-03 00:00:00", + }, + { + "track_id": 703, + "owner_id": 1, + "title": "Gated other mint", + "is_stream_gated": true, + "stream_conditions": map[string]any{ + "token_gate": map[string]any{ + "token_mint": "OtherMintZZZZZZZZZZZZZZZZZZZZZZZZZZ", + "token_amount": 1, + }, + }, + "created_at": "2020-01-04 00:00:00", + }, + }, + }) + + path := testFanClubFeedURL(1, "") + status, body := testGetWithWallet(t, app, path, "0x7d273271690538cf855e5b3002a0dd8c154bb060") + require.Equal(t, 200, status, string(body)) + + enc701, err := trashid.EncodeHashId(701) + require.NoError(t, err) + + jsonAssert(t, body, map[string]any{ + "data.#": 1, + "data.0.item_type": "track", + "data.0.track.title": "Coin gated for feed", + "data.0.track.id": enc701, + "data.1.item_type": "", + "related.tracks.#": 1, + }) +} + +func TestFanClubFeed_FanWithBalanceSeesFeed(t *testing.T) { + app := emptyTestApp(t) + database.Seed(app.pool.Replicas[0], database.FixtureMap{ + "users": testFanClubFeedBaseUsers(), + "artist_coins": { + { + "user_id": 1, + "mint": testFanClubFeedMint, + "decimals": 6, + "ticker": "FCT", + }, + }, + "sol_user_balances": { + { + "user_id": 2, + "mint": testFanClubFeedMint, + "balance": int64(1_000_000), + }, + }, + "tracks": { + { + "track_id": 801, + "owner_id": 1, + "title": "Gated track", + "is_stream_gated": true, + "stream_conditions": map[string]any{ + "token_gate": map[string]any{ + "token_mint": testFanClubFeedMint, + "token_amount": 1, + }, + }, + "created_at": "2020-01-01 00:00:00", + }, + }, + }) + + path := testFanClubFeedURL(2, "") + status, body := testGetWithWallet(t, app, path, "0xc3d1d41e6872ffbd15c473d14fc3a9250be5b5e0") + require.Equal(t, 200, status, string(body)) + + enc801, err := trashid.EncodeHashId(801) + require.NoError(t, err) + + jsonAssert(t, body, map[string]any{ + "data.#": 1, + "data.0.item_type": "track", + "data.0.track.id": enc801, + "data.0.track.title": "Gated track", + }) +} + +func TestFanClubFeed_IncludesTextPostWhenCommentRowPresent(t *testing.T) { + app := emptyTestApp(t) + + commentRow := map[string]any{ + "comment_id": 910, + "user_id": 2, + "entity_id": 1, + "entity_type": "Coin", + "text": "hello fan club", + "created_at": "2020-06-01 00:00:00", + } + + database.Seed(app.pool.Replicas[0], database.FixtureMap{ + "users": testFanClubFeedBaseUsers(), + "artist_coins": { + { + "user_id": 1, + "mint": testFanClubFeedMint, + "decimals": 6, + "ticker": "FCT", + }, + }, + "sol_user_balances": { + { + "user_id": 2, + "mint": testFanClubFeedMint, + "balance": int64(1_000_000), + }, + }, + "tracks": { + { + "track_id": 901, + "owner_id": 1, + "title": "Gated", + "is_stream_gated": true, + "stream_conditions": map[string]any{ + "token_gate": map[string]any{ + "token_mint": testFanClubFeedMint, + "token_amount": 1, + }, + }, + "created_at": "2020-01-01 00:00:00", + }, + }, + "comments": []map[string]any{commentRow}, + }) + + path := testFanClubFeedURL(2, "sort_method=newest") + status, body := testGetWithWallet(t, app, path, "0xc3d1d41e6872ffbd15c473d14fc3a9250be5b5e0") + if status != 200 { + t.Fatalf("expected 200 got %d: %s", status, string(body)) + } + + enc910, err := trashid.EncodeHashId(910) + require.NoError(t, err) + + jsonAssert(t, body, map[string]any{ + "data.#": 2, + "data.0.item_type": "text_post", + "data.0.comment.message": "hello fan club", + "data.0.comment.id": enc910, + "data.1.item_type": "track", + "data.1.track.title": "Gated", + }) +} + +func TestFanClubFeed_FanWithoutBalanceGetsFeedWithRedactedText(t *testing.T) { + app := emptyTestApp(t) + database.Seed(app.pool.Replicas[0], database.FixtureMap{ + "users": testFanClubFeedBaseUsers(), + "artist_coins": { + { + "user_id": 1, + "mint": testFanClubFeedMint, + "decimals": 6, + "ticker": "FCT", + }, + }, + "sol_user_balances": { + { + "user_id": 2, + "mint": testFanClubFeedMint, + "balance": int64(1), + }, + }, + "tracks": { + { + "track_id": 902, + "owner_id": 1, + "title": "Gated", + "is_stream_gated": true, + "stream_conditions": map[string]any{ + "token_gate": map[string]any{ + "token_mint": testFanClubFeedMint, + "token_amount": 1, + }, + }, + "created_at": "2020-01-01 00:00:00", + }, + }, + "comments": []map[string]any{ + { + "comment_id": 920, + "user_id": 2, + "entity_id": 1, + "entity_type": "Coin", + "text": "secret post", + "created_at": "2020-06-01 00:00:00", + }, + }, + }) + + path := testFanClubFeedURL(2, "sort_method=newest") + status, body := testGetWithWallet(t, app, path, "0xc3d1d41e6872ffbd15c473d14fc3a9250be5b5e0") + require.Equal(t, 200, status, string(body)) + + jsonAssert(t, body, map[string]any{ + "data.#": 2, + "data.0.item_type": "text_post", + "data.1.item_type": "track", + }) + jsonAssert(t, body, map[string]any{ + "data.0.comment.message": nil, + }) +} + +func TestFanClubFeed_ExternalSolanaWalletRevealsTextPost(t *testing.T) { + app := emptyTestApp(t) + pub, priv, err := ed25519.GenerateKey(nil) + require.NoError(t, err) + wallet58 := base58.Encode(pub) + msg := "Audius fan club" + sig := ed25519.Sign(priv, []byte(msg)) + + database.Seed(app.pool.Replicas[0], database.FixtureMap{ + "users": testFanClubFeedBaseUsers(), + "artist_coins": { + { + "user_id": 1, + "mint": testFanClubFeedMint, + "decimals": 6, + "ticker": "FCT", + }, + }, + "sol_token_account_balances": { + { + "mint": testFanClubFeedMint, + "account": "fcfeedta1", + "owner": wallet58, + "balance": int64(1_000_000), + }, + }, + "comments": []map[string]any{ + { + "comment_id": 930, + "user_id": 2, + "entity_id": 1, + "entity_type": "Coin", + "text": "holders only", + "created_at": "2020-06-01 00:00:00", + }, + }, + }) + + path := testFanClubFeedURL(0, "") + status, body := testGetWithSolanaHeaders(t, app, path, wallet58, msg, sig) + require.Equal(t, 200, status, string(body)) + + enc930, err := trashid.EncodeHashId(930) + require.NoError(t, err) + jsonAssert(t, body, map[string]any{ + "data.#": 1, + "data.0.item_type": "text_post", + "data.0.comment.message": "holders only", + "data.0.comment.id": enc930, + }) +} diff --git a/api/v1_track_access_info.go b/api/v1_track_access_info.go index b877d30d..f50e409b 100644 --- a/api/v1_track_access_info.go +++ b/api/v1_track_access_info.go @@ -53,15 +53,17 @@ func (app *ApiServer) v1TrackAccessInfo(c *fiber.Ctx) error { myId := app.getMyId(c) trackId := c.Locals("trackId").(int) - // Get the track with extended information - tracks, err := app.queries.Tracks(c.Context(), dbv1.TracksParams{ + params := dbv1.TracksParams{ GetTracksParams: dbv1.GetTracksParams{ MyID: myId, Ids: []int32{int32(trackId)}, AuthedWallet: app.tryGetAuthedWallet(c), IncludeUnlisted: true, }, - }) + } + + // Get the track with extended information (Solana wallet for token gates comes from context via middleware) + tracks, err := app.queries.Tracks(c.Context(), params) if err != nil { return err }