diff --git a/api/dbv1/get_users.sql.go b/api/dbv1/get_users.sql.go index 0fc25a34..6d4cc463 100644 --- a/api/dbv1/get_users.sql.go +++ b/api/dbv1/get_users.sql.go @@ -153,6 +153,7 @@ SELECT has_collectibles, allow_ai_attribution, + preferred_coin_flair_mint, ( SELECT JSON_BUILD_OBJECT( @@ -161,28 +162,47 @@ SELECT 'ticker', ticker )::jsonb FROM artist_coins - WHERE artist_coins.mint = COALESCE( - -- Owned first - ( - SELECT artist_coins.mint - FROM artist_coins - WHERE artist_coins.user_id = u.user_id - LIMIT 1 - ), - -- Then most held - ( - SELECT sol_user_balances.mint - FROM sol_user_balances - JOIN artist_coins ON artist_coins.mint = sol_user_balances.mint -- ensure mapped in artist_coins - WHERE sol_user_balances.user_id = u.user_id - AND sol_user_balances.balance > 0 - AND sol_user_balances.mint != '9LzCMqDgTKYz9Drzqnpgee3SGa89up3a247ypMj2xrqM' -- ignore prod wAUDIO - AND sol_user_balances.mint != 'BELGiMZQ34SDE6x2FUaML2UHDAgBLS64xvhXjX5tBBZo' -- ignore stage wAUDIO - AND sol_user_balances.mint != 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZy4z6cQ' -- ignore USDC - ORDER BY sol_user_balances.balance DESC - LIMIT 1 + WHERE artist_coins.mint = + -- User explicitly disabled flair + CASE WHEN (u.preferred_coin_flair_mint = '') THEN NULL + ELSE COALESCE( + -- Use preferred flair if valid artist coin and user has a balance + CASE WHEN (u.preferred_coin_flair_mint IS NOT NULL) THEN + ( + SELECT sol_user_balances.mint + FROM sol_user_balances + JOIN artist_coins ON artist_coins.mint = sol_user_balances.mint + WHERE sol_user_balances.user_id = u.user_id + AND sol_user_balances.balance > 0 + AND sol_user_balances.mint = u.preferred_coin_flair_mint + LIMIT 1 + ) + ELSE NULL + END, + -- Owned first + ( + SELECT artist_coins.mint + FROM artist_coins + WHERE artist_coins.user_id = u.user_id + LIMIT 1 + ), + -- Then most held + ( + SELECT sol_user_balances.mint + FROM sol_user_balances + JOIN artist_coins ON artist_coins.mint = sol_user_balances.mint -- ensure mapped in artist_coins + WHERE sol_user_balances.user_id = u.user_id + AND sol_user_balances.balance > 0 + AND sol_user_balances.mint NOT IN ( + '9LzCMqDgTKYz9Drzqnpgee3SGa89up3a247ypMj2xrqM', -- ignore prod wAUDIO + 'BELGiMZQ34SDE6x2FUaML2UHDAgBLS64xvhXjX5tBBZo', -- ignore stage wAUDIO + 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZy4z6cQ' -- ignore USDC + ) + ORDER BY sol_user_balances.balance DESC + LIMIT 1 + ) ) - ) + END ) AS artist_coin_badge FROM users u @@ -259,6 +279,7 @@ type GetUsersRow struct { ProfilePictureLegacy interface{} `json:"profile_picture_legacy"` HasCollectibles bool `json:"has_collectibles"` AllowAiAttribution bool `json:"allow_ai_attribution"` + PreferredCoinFlairMint pgtype.Text `json:"preferred_coin_flair_mint"` ArtistCoinBadge json.RawMessage `json:"artist_coin_badge"` } @@ -331,6 +352,7 @@ func (q *Queries) GetUsers(ctx context.Context, arg GetUsersParams) ([]GetUsersR &i.ProfilePictureLegacy, &i.HasCollectibles, &i.AllowAiAttribution, + &i.PreferredCoinFlairMint, &i.ArtistCoinBadge, ); err != nil { return nil, err diff --git a/api/dbv1/models.go b/api/dbv1/models.go index 8a243b3c..b6ddfa72 100644 --- a/api/dbv1/models.go +++ b/api/dbv1/models.go @@ -2247,6 +2247,8 @@ type User struct { Website pgtype.Text `json:"website"` Donation pgtype.Text `json:"donation"` ProfileType *string `json:"profile_type"` + // The mint of the coin which the user has selected as their preferred flair. NULL for auto, empty string for none. + PreferredCoinFlairMint pgtype.Text `json:"preferred_coin_flair_mint"` } type UserBalance struct { diff --git a/api/dbv1/queries/get_users.sql b/api/dbv1/queries/get_users.sql index a26cd892..912f9c8b 100644 --- a/api/dbv1/queries/get_users.sql +++ b/api/dbv1/queries/get_users.sql @@ -137,6 +137,7 @@ SELECT has_collectibles, allow_ai_attribution, + preferred_coin_flair_mint, ( SELECT JSON_BUILD_OBJECT( @@ -145,28 +146,47 @@ SELECT 'ticker', ticker )::jsonb FROM artist_coins - WHERE artist_coins.mint = COALESCE( - -- Owned first - ( - SELECT artist_coins.mint - FROM artist_coins - WHERE artist_coins.user_id = u.user_id - LIMIT 1 - ), - -- Then most held - ( - SELECT sol_user_balances.mint - FROM sol_user_balances - JOIN artist_coins ON artist_coins.mint = sol_user_balances.mint -- ensure mapped in artist_coins - WHERE sol_user_balances.user_id = u.user_id - AND sol_user_balances.balance > 0 - AND sol_user_balances.mint != '9LzCMqDgTKYz9Drzqnpgee3SGa89up3a247ypMj2xrqM' -- ignore prod wAUDIO - AND sol_user_balances.mint != 'BELGiMZQ34SDE6x2FUaML2UHDAgBLS64xvhXjX5tBBZo' -- ignore stage wAUDIO - AND sol_user_balances.mint != 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZy4z6cQ' -- ignore USDC - ORDER BY sol_user_balances.balance DESC - LIMIT 1 + WHERE artist_coins.mint = + -- User explicitly disabled flair + CASE WHEN (u.preferred_coin_flair_mint = '') THEN NULL + ELSE COALESCE( + -- Use preferred flair if valid artist coin and user has a balance + CASE WHEN (u.preferred_coin_flair_mint IS NOT NULL) THEN + ( + SELECT sol_user_balances.mint + FROM sol_user_balances + JOIN artist_coins ON artist_coins.mint = sol_user_balances.mint + WHERE sol_user_balances.user_id = u.user_id + AND sol_user_balances.balance > 0 + AND sol_user_balances.mint = u.preferred_coin_flair_mint + LIMIT 1 + ) + ELSE NULL + END, + -- Owned first + ( + SELECT artist_coins.mint + FROM artist_coins + WHERE artist_coins.user_id = u.user_id + LIMIT 1 + ), + -- Then most held + ( + SELECT sol_user_balances.mint + FROM sol_user_balances + JOIN artist_coins ON artist_coins.mint = sol_user_balances.mint -- ensure mapped in artist_coins + WHERE sol_user_balances.user_id = u.user_id + AND sol_user_balances.balance > 0 + AND sol_user_balances.mint NOT IN ( + '9LzCMqDgTKYz9Drzqnpgee3SGa89up3a247ypMj2xrqM', -- ignore prod wAUDIO + 'BELGiMZQ34SDE6x2FUaML2UHDAgBLS64xvhXjX5tBBZo', -- ignore stage wAUDIO + 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZy4z6cQ' -- ignore USDC + ) + ORDER BY sol_user_balances.balance DESC + LIMIT 1 + ) ) - ) + END ) AS artist_coin_badge FROM users u diff --git a/api/swagger/swagger-v1-full.yaml b/api/swagger/swagger-v1-full.yaml index 2c161047..d1c572a3 100644 --- a/api/swagger/swagger-v1-full.yaml +++ b/api/swagger/swagger-v1-full.yaml @@ -5495,6 +5495,8 @@ components: ticker: type: string description: The coin symbol/ticker + preferred_coin_flair_mint: + type: string bio: type: string cover_photo: @@ -6035,7 +6037,7 @@ components: type: array items: $ref: '#/components/schemas/coin' - coin_members_count_response: + coin_members_count_response: type: object required: - data diff --git a/api/v1_user_test.go b/api/v1_user_test.go index 2a90498f..d3c790bb 100644 --- a/api/v1_user_test.go +++ b/api/v1_user_test.go @@ -61,6 +61,26 @@ func TestGetUserCoinBadges(t *testing.T) { "user_id": 4, "handle": "stereosteve", }, + { + "user_id": 5, + "handle": "user5", + "preferred_coin_flair_mint": "test_mint_address_124", // Prefers STEVE + }, + { + "user_id": 6, + "handle": "user6", + "preferred_coin_flair_mint": "test_mint_address_123", // Prefers TESTCOIN but has zero balance + }, + { + "user_id": 7, + "handle": "user7", + "preferred_coin_flair_mint": "", // Empty string - should show no badge + }, + { + "user_id": 8, + "handle": "user8", + "preferred_coin_flair_mint": "test_mint_address_124", // Prefers STEVE over their own coin + }, }, "artist_coins": { { @@ -87,6 +107,14 @@ func TestGetUserCoinBadges(t *testing.T) { "logo_uri": "https://example.com/audio-logo.png", "created_at": "2024-01-01 00:00:00", }, + { + "ticker": "USER8COIN", + "decimals": 8, + "user_id": 8, + "mint": "test_mint_address_125", + "logo_uri": "https://example.com/user8-logo.png", + "created_at": "2024-01-01 00:00:00", + }, }, "sol_user_balances": { // User 1 has more AUDIO than TESTCOIN, but more TESTCOIN than $TEVE @@ -138,6 +166,55 @@ func TestGetUserCoinBadges(t *testing.T) { "mint": "test_mint_address_123", "balance": 300, }, + // User 5 prefers STEVE and has balance in it (should show STEVE even if they have more of other coins) + { + "user_id": 5, + "mint": "test_mint_address_124", + "balance": 50, + }, + { + "user_id": 5, + "mint": "test_mint_address_123", + "balance": 1000, // Much higher balance but should be ignored due to preference + }, + { + "user_id": 5, + "mint": "9LzCMqDgTKYz9Drzqnpgee3SGa89up3a247ypMj2xrqM", + "balance": 500, + }, + // User 6 prefers TESTCOIN but has zero balance (should fall back to existing logic) + { + "user_id": 6, + "mint": "test_mint_address_123", + "balance": 0, + }, + { + "user_id": 6, + "mint": "test_mint_address_124", + "balance": 200, + }, + // User 7 has empty string preference (should show no badge) + { + "user_id": 7, + "mint": "test_mint_address_123", + "balance": 100, + }, + { + "user_id": 7, + "mint": "test_mint_address_124", + "balance": 200, + }, + // User 8 prefers STEVE over their own coin (should show STEVE despite having their own coin) + { + "user_id": 8, + "mint": "test_mint_address_124", // STEVE - preferred coin + "balance": 100, + }, + { + "user_id": 8, + "mint": "test_mint_address_125", // Their own coin + "balance": 500, // Higher balance but should be ignored due to preference + }, }, } @@ -149,6 +226,7 @@ func TestGetUserCoinBadges(t *testing.T) { assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{ + "data.0.preferred_coin_flair_mint": nil, "data.0.artist_coin_badge.mint": "test_mint_address_123", "data.0.artist_coin_badge.ticker": "TESTCOIN", "data.0.artist_coin_badge.logo_uri": "https://example.com/test-logo.png", @@ -161,7 +239,8 @@ func TestGetUserCoinBadges(t *testing.T) { assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{ - "data.0.artist_coin_badge": nil, + "data.0.preferred_coin_flair_mint": nil, + "data.0.artist_coin_badge": nil, }) } @@ -171,6 +250,7 @@ func TestGetUserCoinBadges(t *testing.T) { assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{ + "data.0.preferred_coin_flair_mint": nil, "data.0.artist_coin_badge.mint": "test_mint_address_123", "data.0.artist_coin_badge.ticker": "TESTCOIN", "data.0.artist_coin_badge.logo_uri": "https://example.com/test-logo.png", @@ -182,6 +262,56 @@ func TestGetUserCoinBadges(t *testing.T) { status, body := testGet(t, app, "/v1/full/users/"+trashid.MustEncodeHashID(4)) assert.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "data.0.preferred_coin_flair_mint": nil, + "data.0.artist_coin_badge.mint": "test_mint_address_124", + "data.0.artist_coin_badge.ticker": "STEVE", + "data.0.artist_coin_badge.logo_uri": "https://example.com/steve-logo.png", + }) + } + + // Preferred flair with non-zero balance takes priority over higher balance coins + { + status, body := testGet(t, app, "/v1/full/users/"+trashid.MustEncodeHashID(5)) + assert.Equal(t, 200, status) + + jsonAssert(t, body, map[string]any{ + "data.0.preferred_coin_flair_mint": "test_mint_address_124", + "data.0.artist_coin_badge.mint": "test_mint_address_124", + "data.0.artist_coin_badge.ticker": "STEVE", + "data.0.artist_coin_badge.logo_uri": "https://example.com/steve-logo.png", + }) + } + + // Preferred flair with zero balance falls back to 'auto' logic (artist's own coin/highest balance) + { + status, body := testGet(t, app, "/v1/full/users/"+trashid.MustEncodeHashID(6)) + assert.Equal(t, 200, status) + + jsonAssert(t, body, map[string]any{ + "data.0.preferred_coin_flair_mint": "test_mint_address_123", + "data.0.artist_coin_badge.mint": "test_mint_address_124", + "data.0.artist_coin_badge.ticker": "STEVE", + "data.0.artist_coin_badge.logo_uri": "https://example.com/steve-logo.png", + }) + } + + // Empty string preferred flair should return no badge even if user has balances + { + status, body := testGet(t, app, "/v1/full/users/"+trashid.MustEncodeHashID(7)) + assert.Equal(t, 200, status) + + jsonAssert(t, body, map[string]any{ + "data.0.preferred_coin_flair_mint": "", + "data.0.artist_coin_badge": nil, + }) + } + + // Preferred flair takes priority over user's own artist coin + { + status, body := testGet(t, app, "/v1/full/users/"+trashid.MustEncodeHashID(8)) + assert.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ "data.0.artist_coin_badge.mint": "test_mint_address_124", "data.0.artist_coin_badge.ticker": "STEVE", diff --git a/ddl/migrations/0175_add_user_coin_badge_preference.sql b/ddl/migrations/0175_add_user_coin_badge_preference.sql new file mode 100644 index 00000000..ff88af5c --- /dev/null +++ b/ddl/migrations/0175_add_user_coin_badge_preference.sql @@ -0,0 +1,6 @@ +BEGIN; + +ALTER TABLE users ADD COLUMN IF NOT EXISTS preferred_coin_flair_mint TEXT DEFAULT NULL; +COMMENT ON COLUMN users.preferred_coin_flair_mint IS 'The mint of the coin which the user has selected as their preferred flair. NULL for auto, empty string for none.'; + +COMMIT; \ No newline at end of file diff --git a/sql/01_schema.sql b/sql/01_schema.sql index 8e0c69b7..dca8b9aa 100644 --- a/sql/01_schema.sql +++ b/sql/01_schema.sql @@ -3,8 +3,8 @@ -- --- Dumped from database version 17.6 (Debian 17.6-2.pgdg13+1) --- Dumped by pg_dump version 17.6 (Debian 17.6-2.pgdg13+1) +-- Dumped from database version 17.6 (Debian 17.6-1.pgdg13+1) +-- Dumped by pg_dump version 17.6 (Debian 17.6-1.pgdg13+1) SET statement_timeout = 0; SET lock_timeout = 0; @@ -8062,10 +8062,18 @@ CREATE TABLE public.users ( verified_with_tiktok boolean DEFAULT false, website character varying, donation character varying, - profile_type public.profile_type_enum + profile_type public.profile_type_enum, + preferred_coin_flair_mint text ); +-- +-- Name: COLUMN users.preferred_coin_flair_mint; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.users.preferred_coin_flair_mint IS 'The mint of the coin which the user has selected as their preferred flair. NULL for auto, empty string for none.'; + + -- -- Name: trending_params; Type: MATERIALIZED VIEW; Schema: public; Owner: - --