diff --git a/api/comms/chat.go b/api/comms/chat.go index fe493782..247485ae 100644 --- a/api/comms/chat.go +++ b/api/comms/chat.go @@ -250,6 +250,30 @@ func isInPermitList(permit ChatPermission, permitList []ChatPermission) bool { return false } +// chatSetCategory sets (or, when category is nil, clears) the calling user's +// inbox category for a chat. Like updatePermissions/chatUnblock it is guarded +// by the RPC timestamp so a late-arriving RPC can't clobber newer state. +func chatSetCategory(db dbv1.DBTX, ctx context.Context, userId int32, chatId string, category *string, messageTimestamp time.Time) error { + var err error + if category != nil { + _, err = db.Exec(ctx, ` + insert into user_conversation_preferences + (user_id, chat_id, category, updated_at) + values + ($1, $2, $3, $4) + on conflict (user_id, chat_id) + do update set category = excluded.category, updated_at = excluded.updated_at + where user_conversation_preferences.updated_at < excluded.updated_at`, + userId, chatId, *category, messageTimestamp.UTC()) + } else { + _, err = db.Exec(ctx, ` + delete from user_conversation_preferences + where user_id = $1 and chat_id = $2 and updated_at < $3`, + userId, chatId, messageTimestamp.UTC()) + } + return err +} + func updatePermissions(db dbv1.DBTX, ctx context.Context, userId int32, permit ChatPermission, permitAllowed bool, messageTimestamp time.Time) error { _, err := db.Exec(ctx, ` insert into chat_permissions (user_id, permits, allowed, updated_at) diff --git a/api/comms/chat_set_category_test.go b/api/comms/chat_set_category_test.go new file mode 100644 index 00000000..d9534090 --- /dev/null +++ b/api/comms/chat_set_category_test.go @@ -0,0 +1,127 @@ +package comms + +import ( + "context" + "fmt" + "math/rand" + "strconv" + "testing" + "time" + + "api.audius.co/database" + "api.audius.co/trashid" + "github.com/jackc/pgx/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestChatSetCategory(t *testing.T) { + pool := database.CreateTestDatabase(t, "test_comms") + defer pool.Close() + + ctx := context.Background() + + chatId := trashid.ChatID(1, 2) + + seededRand := rand.New(rand.NewSource(time.Now().UnixNano())) + inviteCode1 := strconv.Itoa(seededRand.Int()) + inviteCode2 := strconv.Itoa(seededRand.Int()) + + SetupChatWithMembers(t, pool, ctx, chatId, 1, 2, inviteCode1, inviteCode2) + + // getCategory returns the stored category for (userId, chatId), or nil when + // there is no row (uncategorized). + getCategory := func(userId int32) *string { + var category string + err := pool.QueryRow(ctx, + "select category from user_conversation_preferences where user_id = $1 and chat_id = $2", + userId, chatId).Scan(&category) + if err == pgx.ErrNoRows { + return nil + } + require.NoError(t, err) + return &category + } + + validator := CreateTestValidator(t, pool, DefaultRateLimitConfig, DefaultTestValidatorConfig) + + // Validation + { + priorityRpc := RawRPC{ + Params: []byte(fmt.Sprintf(`{"chat_id": "%s", "category": "priority"}`, chatId)), + } + generalRpc := RawRPC{ + Params: []byte(fmt.Sprintf(`{"chat_id": "%s", "category": "general"}`, chatId)), + } + nullRpc := RawRPC{ + Params: []byte(fmt.Sprintf(`{"chat_id": "%s", "category": null}`, chatId)), + } + bogusRpc := RawRPC{ + Params: []byte(fmt.Sprintf(`{"chat_id": "%s", "category": "spam"}`, chatId)), + } + + // members may set any valid category or clear it + assert.NoError(t, validator.validateChatSetCategory(1, priorityRpc)) + assert.NoError(t, validator.validateChatSetCategory(2, generalRpc)) + assert.NoError(t, validator.validateChatSetCategory(1, nullRpc)) + + // non-members are rejected + err := validator.validateChatSetCategory(3, priorityRpc) + assert.Error(t, err, "User 3 is not a member and should not be able to set a category") + assert.Contains(t, err.Error(), "user is not a member of this chat") + + // unknown category values are rejected, even for members + err = validator.validateChatSetCategory(1, bogusRpc) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid chat category") + + // the full Validate entrypoint routes chat.set_category to the validator + err = validator.Validate(ctx, 3, RawRPC{Method: string(RPCMethodChatSetCategory), Params: priorityRpc.Params}) + assert.Error(t, err) + } + + general := string(ChatCategoryGeneral) + priority := string(ChatCategoryPriority) + + // no row to start with + assert.Nil(t, getCategory(1)) + + // set "general" + t1 := time.Now().UTC().Add(-time.Minute) + require.NoError(t, chatSetCategory(pool, ctx, 1, chatId, &general, t1)) + if got := getCategory(1); assert.NotNil(t, got) { + assert.Equal(t, general, *got) + } + + // update to "priority" with a newer timestamp + t2 := t1.Add(10 * time.Second) + require.NoError(t, chatSetCategory(pool, ctx, 1, chatId, &priority, t2)) + if got := getCategory(1); assert.NotNil(t, got) { + assert.Equal(t, priority, *got) + } + + // a late-arriving older RPC must not clobber newer state + tOld := t1.Add(5 * time.Second) + require.NoError(t, chatSetCategory(pool, ctx, 1, chatId, &general, tOld)) + if got := getCategory(1); assert.NotNil(t, got) { + assert.Equal(t, priority, *got, "older set_category should be ignored") + } + + // the other member's preference is independent + assert.Nil(t, getCategory(2)) + + // clearing with an older timestamp must not delete the newer row + require.NoError(t, chatSetCategory(pool, ctx, 1, chatId, nil, tOld)) + if got := getCategory(1); assert.NotNil(t, got) { + assert.Equal(t, priority, *got, "older clear should be ignored") + } + + // clearing with a newer timestamp deletes the row + t3 := t2.Add(10 * time.Second) + require.NoError(t, chatSetCategory(pool, ctx, 1, chatId, nil, t3)) + assert.Nil(t, getCategory(1)) + + // clearing when there is no row is a no-op, not an error + require.NoError(t, chatSetCategory(pool, ctx, 1, chatId, nil, t3.Add(time.Second))) + assert.Nil(t, getCategory(1)) +} diff --git a/api/comms/rpc_processor.go b/api/comms/rpc_processor.go index c05268bb..6e9f5b0e 100644 --- a/api/comms/rpc_processor.go +++ b/api/comms/rpc_processor.go @@ -277,6 +277,16 @@ select last_active_at from chat_member where chat_id = $1 and user_id = $2` if err != nil { return err } + case RPCMethodChatSetCategory: + var params ChatSetCategoryRPCParams + err = json.Unmarshal(rawRpc.Params, ¶ms) + if err != nil { + return err + } + err = chatSetCategory(tx, ctx, userId, params.ChatID, params.Category, messageTs) + if err != nil { + return err + } case RPCMethodChatPermit: var params ChatPermitRPCParams err = json.Unmarshal(rawRpc.Params, ¶ms) diff --git a/api/comms/schema.go b/api/comms/schema.go index 1d527512..1a09d1f1 100644 --- a/api/comms/schema.go +++ b/api/comms/schema.go @@ -102,6 +102,18 @@ type ChatReadAllRPC struct { type ChatReadAllRPCParams struct{} +type ChatSetCategoryRPC struct { + Method ChatSetCategoryRPCMethod `json:"method"` + Params ChatSetCategoryRPCParams `json:"params"` +} + +// ChatSetCategoryRPCParams sets the calling user's inbox category for a chat. +// Category is "priority" or "general"; null clears it (chat becomes uncategorized). +type ChatSetCategoryRPCParams struct { + ChatID string `json:"chat_id"` + Category *string `json:"category"` +} + type ChatBlockRPC struct { Method ChatBlockRPCMethod `json:"method"` Params ChatBlockRPCParams `json:"params"` @@ -149,6 +161,7 @@ type RPCPayloadRequestParams struct { MessageID *string `json:"message_id,omitempty"` ParentMessageID *string `json:"parent_message_id,omitempty"` Reaction *string `json:"reaction"` + Category *string `json:"category"` UserID *string `json:"user_id,omitempty"` Allow *bool `json:"allow,omitempty"` Permit *ChatPermission `json:"permit,omitempty"` @@ -164,6 +177,7 @@ type UserChat struct { Audience ChatBlastAudience `json:"audience"` AudienceContentID *string `json:"audience_content_id,omitempty"` AudienceContentType *string `json:"audience_content_type,omitempty"` + Category *string `json:"category"` ChatID string `json:"chat_id"` ChatMembers []ChatMember `json:"chat_members"` ClearedHistoryAt string `json:"cleared_history_at"` @@ -298,6 +312,7 @@ type RPCPayloadParams struct { MessageID *string `json:"message_id,omitempty"` ParentMessageID *string `json:"parent_message_id,omitempty"` Reaction *string `json:"reaction"` + Category *string `json:"category"` UserID *string `json:"user_id,omitempty"` Allow *bool `json:"allow,omitempty"` Permit *ChatPermission `json:"permit,omitempty"` @@ -380,6 +395,12 @@ const ( MethodChatReadAll ChatReadAllRPCMethod = "chat.read_all" ) +type ChatSetCategoryRPCMethod string + +const ( + MethodChatSetCategory ChatSetCategoryRPCMethod = "chat.set_category" +) + type ChatBlockRPCMethod string const ( @@ -411,6 +432,14 @@ const ( ChatPermissionVerified ChatPermission = "verified" ) +// Per-user inbox category for a chat. A chat with no category is "uncategorized". +type ChatCategory string + +const ( + ChatCategoryPriority ChatCategory = "priority" + ChatCategoryGeneral ChatCategory = "general" +) + type RPCMethod string const ( @@ -424,6 +453,7 @@ const ( RPCMethodChatReact RPCMethod = "chat.react" RPCMethodChatRead RPCMethod = "chat.read" RPCMethodChatReadAll RPCMethod = "chat.read_all" + RPCMethodChatSetCategory RPCMethod = "chat.set_category" RPCMethodChatUnblock RPCMethod = "chat.unblock" RPCMethodUserValidateCanChat RPCMethod = "user.validate_can_chat" ) diff --git a/api/comms/validator.go b/api/comms/validator.go index af8d5260..9066f5e4 100644 --- a/api/comms/validator.go +++ b/api/comms/validator.go @@ -70,6 +70,8 @@ func (vtor *Validator) Validate(ctx context.Context, userId int32, rawRpc RawRPC case RPCMethodChatReadAll: // No params to validate; ban check above already gates this call. return nil + case RPCMethodChatSetCategory: + return vtor.validateChatSetCategory(userId, rawRpc) case RPCMethodChatPermit: return vtor.validateChatPermit(userId, rawRpc) case RPCMethodChatBlock: @@ -250,6 +252,32 @@ func (vtor *Validator) validateChatRead(userId int32, rpc RawRPC) error { return nil } +func (vtor *Validator) validateChatSetCategory(userId int32, rpc RawRPC) error { + // validate rpc.params valid + var params ChatSetCategoryRPCParams + err := json.Unmarshal(rpc.Params, ¶ms) + if err != nil { + return err + } + + // validate category is one of the known values (nil clears the category) + if params.Category != nil { + switch ChatCategory(*params.Category) { + case ChatCategoryPriority, ChatCategoryGeneral: + default: + return fmt.Errorf("invalid chat category %q: must be %q, %q, or null", *params.Category, ChatCategoryPriority, ChatCategoryGeneral) + } + } + + // validate userId is a member of chatId in good standing + err = validateChatMembership(vtor.pool, context.Background(), userId, params.ChatID) + if err != nil { + return err + } + + return nil +} + func (vtor *Validator) validateChatPermit(userId int32, rpc RawRPC) error { // validate rpc.params valid var params ChatPermitRPCParams diff --git a/api/comms_chat.go b/api/comms_chat.go index b1968087..91f5cf4e 100644 --- a/api/comms_chat.go +++ b/api/comms_chat.go @@ -28,14 +28,18 @@ func (app *ApiServer) getChat(c *fiber.Ctx) error { NULL AS audience_content_id, ( SELECT json_agg(json_build_object( - 'user_id', chat_member.user_id, - 'cleared_history_at', chat_member.cleared_history_at + 'user_id', m.user_id, + 'cleared_history_at', m.cleared_history_at )) - FROM chat_member - WHERE chat_member.chat_id = chat.chat_id - )::jsonb AS members + FROM chat_member m + WHERE m.chat_id = chat.chat_id + )::jsonb AS members, + ucp.category::text AS category FROM chat_member JOIN chat ON chat.chat_id = chat_member.chat_id + LEFT JOIN user_conversation_preferences ucp + ON ucp.user_id = chat_member.user_id + AND ucp.chat_id = chat_member.chat_id WHERE chat_member.user_id = @user_id AND chat_member.chat_id = @chat_id -- Query blasts as well @@ -58,7 +62,8 @@ func (app *ApiServer) getChat(c *fiber.Ctx) error { audience, audience_content_type, audience_content_id, - json_build_array()::jsonb AS members + json_build_array()::jsonb AS members, + NULL::text AS category FROM chat_blast b WHERE from_user_id = @user_id AND concat_ws(':', audience, audience_content_type, diff --git a/api/comms_chats.go b/api/comms_chats.go index d8ddfbe3..db8fa5cb 100644 --- a/api/comms_chats.go +++ b/api/comms_chats.go @@ -35,14 +35,18 @@ func (app *ApiServer) getChats(c *fiber.Ctx) error { NULL AS audience_content_id, ( SELECT json_agg(json_build_object( - 'user_id', chat_member.user_id, - 'cleared_history_at', chat_member.cleared_history_at + 'user_id', m.user_id, + 'cleared_history_at', m.cleared_history_at )) - FROM chat_member - WHERE chat_member.chat_id = chat.chat_id - )::jsonb AS members + FROM chat_member m + WHERE m.chat_id = chat.chat_id + )::jsonb AS members, + ucp.category::text AS category FROM chat_member JOIN chat ON chat.chat_id = chat_member.chat_id + LEFT JOIN user_conversation_preferences ucp + ON ucp.user_id = chat_member.user_id + AND ucp.chat_id = chat_member.chat_id WHERE chat_member.user_id = @user_id AND chat_member.is_hidden = false AND chat.last_message IS NOT NULL @@ -71,7 +75,8 @@ func (app *ApiServer) getChats(c *fiber.Ctx) error { audience, audience_content_type, audience_content_id, - json_build_array()::jsonb AS members + json_build_array()::jsonb AS members, + NULL::text AS category FROM chat_blast WHERE from_user_id = @user_id AND created_at < @before diff --git a/api/comms_chats_unread_by_category.go b/api/comms_chats_unread_by_category.go new file mode 100644 index 00000000..9d34c8b6 --- /dev/null +++ b/api/comms_chats_unread_by_category.go @@ -0,0 +1,52 @@ +package api + +import ( + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" +) + +// UnreadCountByCategory is the number of chats with unread messages, grouped +// by the current user's inbox category for each chat. Chats with no +// user_conversation_preferences row are "uncategorized". +type UnreadCountByCategory struct { + Priority int `json:"priority"` + General int `json:"general"` + Uncategorized int `json:"uncategorized"` +} + +func (app *ApiServer) getUnreadCountByCategory(c *fiber.Ctx) error { + // Mirrors getUnreadCount's filter (unread_count > 0 on the user's + // chat_member rows) and splits the count by category. + sql := ` + SELECT + COUNT(*) FILTER (WHERE ucp.category = 'priority') AS priority, + COUNT(*) FILTER (WHERE ucp.category = 'general') AS general, + COUNT(*) FILTER (WHERE ucp.category IS NULL) AS uncategorized + FROM chat_member + LEFT JOIN user_conversation_preferences ucp + ON ucp.user_id = chat_member.user_id + AND ucp.chat_id = chat_member.chat_id + WHERE chat_member.user_id = @user_id AND chat_member.unread_count > 0 + ;` + + wallet := app.getAuthedWallet(c) + userId, err := app.getUserIDFromWallet(c.Context(), wallet) + if err != nil { + return err + } + + counts := UnreadCountByCategory{} + err = app.pool.QueryRow(c.Context(), sql, pgx.NamedArgs{ + "user_id": userId, + }).Scan(&counts.Priority, &counts.General, &counts.Uncategorized) + if err != nil && err != pgx.ErrNoRows { + return err + } + + return c.JSON(CommsResponse{ + Data: counts, + Health: CommsHealth{ + IsHealthy: true, + }, + }) +} diff --git a/api/comms_mutate_test.go b/api/comms_mutate_test.go index 81370401..80432719 100644 --- a/api/comms_mutate_test.go +++ b/api/comms_mutate_test.go @@ -1,8 +1,11 @@ package api import ( + "encoding/hex" "encoding/json" "fmt" + "io" + "net/http/httptest" "strings" "testing" "time" @@ -11,7 +14,10 @@ import ( "api.audius.co/api/testdata" "api.audius.co/database" "api.audius.co/trashid" + "github.com/ethereum/go-ethereum/crypto" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" ) // dummy pkeys generated from ganache "test test...junk" seed @@ -120,3 +126,249 @@ func TestPostMutateChat(t *testing.T) { } }) } + +// testGetWithTestWallet makes a GET request authenticated with signature +// headers signed by the given TestWallet (EIP-191 personal message), for +// wallets that do not have canned signatures in testdata.TestSignatures. +func testGetWithTestWallet(t *testing.T, app *ApiServer, path string, wallet *testdata.TestWallet) (int, []byte) { + t.Helper() + + message := fmt.Sprintf("signature:%d", time.Now().UnixMilli()) + prefixedMsg := []byte(fmt.Sprintf("\x19Ethereum Signed Message:\n%d%s", len(message), message)) + finalHash := crypto.Keccak256Hash(prefixedMsg) + sigBytes, err := crypto.Sign(finalHash.Bytes(), wallet.PrivateKey) + require.NoError(t, err) + + req := httptest.NewRequest("GET", path, nil) + req.Header.Set("Encoded-Data-Message", message) + req.Header.Set("Encoded-Data-Signature", "0x"+hex.EncodeToString(sigBytes)) + + res, err := app.Test(req, -1) + require.NoError(t, err) + body, _ := io.ReadAll(res.Body) + return res.StatusCode, body +} + +func TestPostMutateChatSetCategory(t *testing.T) { + testWallet1 := testdata.CreateTestWallet(t, user1WalletKey) + app := emptyTestApp(t) + + now := time.Now() + database.Seed(app.writePool, database.FixtureMap{ + "users": { + { + "user_id": 1, + "handle": "user1", + "wallet": strings.ToLower(testWallet1.Address), + "created_at": now.Add(-time.Hour), + "updated_at": now.Add(-time.Hour), + "is_current": true, + }, + { + "user_id": 2, + "handle": "user2", + "wallet": "0x7d273271690538cf855e5b3002a0dd8c154bb060", + "created_at": now.Add(-time.Hour), + "updated_at": now.Add(-time.Hour), + "is_current": true, + }, + }, + }) + + user1EncodedID := trashid.MustEncodeHashID(1) + user2EncodedID := trashid.MustEncodeHashID(2) + chatId := trashid.ChatID(1, 2) + chatUrl := fmt.Sprintf("/comms/chats/%s", chatId) + + // create the chat and send a message so it shows up in GET /comms/chats + // (which requires last_message to be set) + { + status, _ := postMutateRPCData(t, app, user1EncodedID, comms.RPCMethodChatCreate, comms.ChatCreateRPCParams{ + ChatID: chatId, + Invites: []comms.PurpleInvite{ + {UserID: user1EncodedID, InviteCode: "test"}, + {UserID: user2EncodedID, InviteCode: "test"}, + }, + }, now.UnixMilli(), testWallet1) + require.Equal(t, 200, status) + + status, _ = postMutateRPCData(t, app, user1EncodedID, comms.RPCMethodChatMessage, comms.ChatMessageRPCParams{ + ChatID: chatId, + MessageID: "msg1", + Message: "hello", + }, now.Add(time.Second).UnixMilli(), testWallet1) + require.Equal(t, 200, status) + } + + // uncategorized by default + { + status, body := testGetWithTestWallet(t, app, chatUrl, testWallet1) + require.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "data.chat_id": chatId, + "data.category": nil, + }) + // the key must be present (null), not omitted + assert.True(t, gjson.GetBytes(body, "data.category").Exists()) + + status, body = testGetWithTestWallet(t, app, "/comms/chats", testWallet1) + require.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "data.0.chat_id": chatId, + "data.0.category": nil, + }) + } + + // set category to "priority" + { + category := "priority" + status, _ := postMutateRPCData(t, app, user1EncodedID, comms.RPCMethodChatSetCategory, comms.ChatSetCategoryRPCParams{ + ChatID: chatId, + Category: &category, + }, now.Add(2*time.Second).UnixMilli(), testWallet1) + require.Equal(t, 200, status) + + status, body := testGetWithTestWallet(t, app, chatUrl, testWallet1) + require.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{"data.category": "priority"}) + + status, body = testGetWithTestWallet(t, app, "/comms/chats", testWallet1) + require.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{"data.0.category": "priority"}) + + // the category is per-user: user 2 still sees the chat as uncategorized + status, body = testGetWithWallet(t, app, chatUrl, "0x7d273271690538cf855e5b3002a0dd8c154bb060") + require.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{"data.category": nil}) + } + + // change to "general" + { + category := "general" + status, _ := postMutateRPCData(t, app, user1EncodedID, comms.RPCMethodChatSetCategory, comms.ChatSetCategoryRPCParams{ + ChatID: chatId, + Category: &category, + }, now.Add(3*time.Second).UnixMilli(), testWallet1) + require.Equal(t, 200, status) + + status, body := testGetWithTestWallet(t, app, chatUrl, testWallet1) + require.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{"data.category": "general"}) + } + + // invalid category is rejected by the validator + { + category := "spam" + status, _ := postMutateRPCData(t, app, user1EncodedID, comms.RPCMethodChatSetCategory, comms.ChatSetCategoryRPCParams{ + ChatID: chatId, + Category: &category, + }, now.Add(4*time.Second).UnixMilli(), testWallet1) + assert.Equal(t, 400, status) + + status, body := testGetWithTestWallet(t, app, chatUrl, testWallet1) + require.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{"data.category": "general"}) + } + + // null clears the category + { + status, _ := postMutateRPCData(t, app, user1EncodedID, comms.RPCMethodChatSetCategory, comms.ChatSetCategoryRPCParams{ + ChatID: chatId, + Category: nil, + }, now.Add(5*time.Second).UnixMilli(), testWallet1) + require.Equal(t, 200, status) + + status, body := testGetWithTestWallet(t, app, chatUrl, testWallet1) + require.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{"data.category": nil}) + assert.True(t, gjson.GetBytes(body, "data.category").Exists()) + } +} + +func TestGetUnreadCountByCategory(t *testing.T) { + app := emptyTestApp(t) + + now := time.Now() + wallet := "0x7d273271690538cf855e5b3002a0dd8c154bb060" + url := "/comms/chats/unread_by_category" + + // user 1 is the current user; user 2 is the other member of every chat + fixtures := database.FixtureMap{ + "users": { + {"user_id": 1, "handle": "user1", "wallet": wallet, "created_at": now, "updated_at": now, "is_current": true}, + {"user_id": 2, "handle": "user2", "wallet": "wallet2", "created_at": now, "updated_at": now, "is_current": true}, + }, + "chat": {}, + "chat_member": {}, + "user_conversation_preferences": {}, + } + + // (chat_id, category for user 1, unread_count for user 1) + type chatSpec struct { + id string + category string // "" = uncategorized + unread int + } + specs := []chatSpec{ + {"chat_priority_1", "priority", 3}, + {"chat_priority_2", "priority", 1}, + {"chat_priority_read", "priority", 0}, // read: not counted + {"chat_general_1", "general", 5}, + {"chat_general_read", "general", 0}, // read: not counted + {"chat_uncat_1", "", 2}, + {"chat_uncat_2", "", 1}, + {"chat_uncat_3", "", 1}, + {"chat_uncat_read", "", 0}, // read: not counted + } + for _, s := range specs { + fixtures["chat"] = append(fixtures["chat"], map[string]any{ + "chat_id": s.id, "last_message": "hi", "last_message_at": now, + }) + fixtures["chat_member"] = append(fixtures["chat_member"], + map[string]any{"chat_id": s.id, "user_id": 1, "invited_by_user_id": 1, "invite_code": "x", "unread_count": s.unread}, + // the other member has everything unread, but that must not count for user 1 + map[string]any{"chat_id": s.id, "user_id": 2, "invited_by_user_id": 1, "invite_code": "x", "unread_count": 9}, + ) + if s.category != "" { + fixtures["user_conversation_preferences"] = append(fixtures["user_conversation_preferences"], map[string]any{ + "user_id": 1, "chat_id": s.id, "category": s.category, + }) + } + } + // user 2's own preference for a chat must not affect user 1's counts + fixtures["user_conversation_preferences"] = append(fixtures["user_conversation_preferences"], map[string]any{ + "user_id": 2, "chat_id": "chat_uncat_1", "category": "priority", + }) + + database.Seed(app.writePool, fixtures) + + status, body := testGetWithWallet(t, app, url, wallet) + require.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "data.priority": 2, + "data.general": 1, + "data.uncategorized": 3, + }) + + // sanity: the plain unread endpoint agrees with the sum + status, body = testGetWithWallet(t, app, "/comms/chats/unread", wallet) + require.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{"data": 6}) + + // a user with no unread chats gets all three keys, each zero + database.Seed(app.writePool, database.FixtureMap{ + "users": { + {"user_id": 3, "handle": "user3", "wallet": "0xc3d1d41e6872ffbd15c473d14fc3a9250be5b5e0", "created_at": now, "updated_at": now, "is_current": true}, + }, + }) + status, body = testGetWithWallet(t, app, url, "0xc3d1d41e6872ffbd15c473d14fc3a9250be5b5e0") + require.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "data.priority": 0, + "data.general": 0, + "data.uncategorized": 0, + }) + for _, key := range []string{"data.priority", "data.general", "data.uncategorized"} { + assert.True(t, gjson.GetBytes(body, key).Exists(), "%s must always be present", key) + } +} diff --git a/api/dbv1/models.go b/api/dbv1/models.go index 1eef445b..9c6da6e6 100644 --- a/api/dbv1/models.go +++ b/api/dbv1/models.go @@ -190,6 +190,49 @@ func (ns NullDelistUserReason) Value() (driver.Value, error) { return string(ns.DelistUserReason), nil } +type EtlProofStatus string + +const ( + EtlProofStatusUnresolved EtlProofStatus = "unresolved" + EtlProofStatusPass EtlProofStatus = "pass" + EtlProofStatusFail EtlProofStatus = "fail" +) + +func (e *EtlProofStatus) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = EtlProofStatus(s) + case string: + *e = EtlProofStatus(s) + default: + return fmt.Errorf("unsupported scan type for EtlProofStatus: %T", src) + } + return nil +} + +type NullEtlProofStatus struct { + EtlProofStatus EtlProofStatus `json:"etl_proof_status"` + Valid bool `json:"valid"` // Valid is true if EtlProofStatus is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullEtlProofStatus) Scan(value interface{}) error { + if value == nil { + ns.EtlProofStatus, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.EtlProofStatus.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullEtlProofStatus) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.EtlProofStatus), nil +} + type EventEntityType string const ( @@ -918,6 +961,14 @@ type ArtistCoinPrice struct { Price int32 `json:"price"` } +// Hourly USD price snapshots per artist coin, used to compute 24h price change. +type ArtistCoinPriceHistory struct { + Mint string `json:"mint"` + Timestamp time.Time `json:"timestamp"` + Price float64 `json:"price"` + CreatedAt time.Time `json:"created_at"` +} + type ArtistCoinStat struct { Mint string `json:"mint"` MarketCap pgtype.Float8 `json:"market_cap"` @@ -1321,6 +1372,159 @@ type EthWalletBalance struct { CreatedAt time.Time `json:"created_at"` } +type EtlAddress struct { + ID int32 `json:"id"` + Address string `json:"address"` + PubKey []byte `json:"pub_key"` + FirstSeenBlockHeight pgtype.Int8 `json:"first_seen_block_height"` + CreatedAt time.Time `json:"created_at"` +} + +type EtlBlock struct { + ID int32 `json:"id"` + ProposerAddress string `json:"proposer_address"` + BlockHeight int64 `json:"block_height"` + BlockTime time.Time `json:"block_time"` +} + +type EtlDbMigration struct { + Version int64 `json:"version"` + Dirty bool `json:"dirty"` +} + +type EtlManageEntity struct { + ID int32 `json:"id"` + Address string `json:"address"` + EntityType string `json:"entity_type"` + EntityID int64 `json:"entity_id"` + Action string `json:"action"` + Metadata pgtype.Text `json:"metadata"` + Signature string `json:"signature"` + Signer string `json:"signer"` + Nonce string `json:"nonce"` + BlockHeight int64 `json:"block_height"` + TxHash string `json:"tx_hash"` + CreatedAt time.Time `json:"created_at"` +} + +type EtlPlay struct { + ID int32 `json:"id"` + UserID string `json:"user_id"` + TrackID string `json:"track_id"` + City string `json:"city"` + Region string `json:"region"` + Country string `json:"country"` + PlayedAt time.Time `json:"played_at"` + BlockHeight int64 `json:"block_height"` + TxHash string `json:"tx_hash"` + ListenedAt time.Time `json:"listened_at"` + RecordedAt time.Time `json:"recorded_at"` +} + +type EtlSlaNodeReport struct { + ID int32 `json:"id"` + SlaRollupID int32 `json:"sla_rollup_id"` + Address string `json:"address"` + NumBlocksProposed int32 `json:"num_blocks_proposed"` + ChallengesReceived int32 `json:"challenges_received"` + ChallengesFailed int32 `json:"challenges_failed"` + BlockHeight int64 `json:"block_height"` + TxHash string `json:"tx_hash"` + CreatedAt time.Time `json:"created_at"` +} + +type EtlSlaRollup struct { + ID int32 `json:"id"` + BlockStart int64 `json:"block_start"` + BlockEnd int64 `json:"block_end"` + BlockHeight int64 `json:"block_height"` + ValidatorCount int32 `json:"validator_count"` + BlockQuota int32 `json:"block_quota"` + Bps float64 `json:"bps"` + Tps float64 `json:"tps"` + TxHash string `json:"tx_hash"` + CreatedAt time.Time `json:"created_at"` +} + +type EtlStorageProof struct { + ID int32 `json:"id"` + Height int64 `json:"height"` + Address string `json:"address"` + ProverAddresses []string `json:"prover_addresses"` + Cid string `json:"cid"` + ProofSignature []byte `json:"proof_signature"` + Proof []byte `json:"proof"` + Status EtlProofStatus `json:"status"` + BlockHeight int64 `json:"block_height"` + TxHash string `json:"tx_hash"` + CreatedAt time.Time `json:"created_at"` +} + +type EtlStorageProofVerification struct { + ID int32 `json:"id"` + Height int64 `json:"height"` + Proof []byte `json:"proof"` + BlockHeight int64 `json:"block_height"` + TxHash string `json:"tx_hash"` + CreatedAt time.Time `json:"created_at"` +} + +type EtlTransaction struct { + ID int32 `json:"id"` + TxHash string `json:"tx_hash"` + BlockHeight int64 `json:"block_height"` + TxIndex int32 `json:"tx_index"` + TxType string `json:"tx_type"` + Address pgtype.Text `json:"address"` + CreatedAt time.Time `json:"created_at"` +} + +type EtlValidator struct { + ID int32 `json:"id"` + Address string `json:"address"` + Endpoint string `json:"endpoint"` + CometAddress string `json:"comet_address"` + NodeType string `json:"node_type"` + Spid string `json:"spid"` + VotingPower int64 `json:"voting_power"` + Status string `json:"status"` + RegisteredAt int64 `json:"registered_at"` + DeregisteredAt pgtype.Int8 `json:"deregistered_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type EtlValidatorDeregistration struct { + ID int32 `json:"id"` + CometAddress string `json:"comet_address"` + CometPubkey []byte `json:"comet_pubkey"` + BlockHeight int64 `json:"block_height"` + TxHash string `json:"tx_hash"` +} + +type EtlValidatorMisbehaviorDeregistration struct { + ID int32 `json:"id"` + CometAddress string `json:"comet_address"` + PubKey []byte `json:"pub_key"` + BlockHeight int64 `json:"block_height"` + TxHash string `json:"tx_hash"` + CreatedAt time.Time `json:"created_at"` +} + +type EtlValidatorRegistration struct { + ID int32 `json:"id"` + Address string `json:"address"` + Endpoint string `json:"endpoint"` + CometAddress string `json:"comet_address"` + EthBlock string `json:"eth_block"` + NodeType string `json:"node_type"` + Spid string `json:"spid"` + CometPubkey []byte `json:"comet_pubkey"` + VotingPower int64 `json:"voting_power"` + BlockHeight int64 `json:"block_height"` + TxHash string `json:"tx_hash"` +} + type Event struct { EventID int32 `json:"event_id"` EventType EventType `json:"event_type"` @@ -1337,6 +1541,16 @@ type Event struct { Blocknumber pgtype.Int4 `json:"blocknumber"` } +type EventRoute struct { + Slug string `json:"slug"` + OwnerID int32 `json:"owner_id"` + EventID int32 `json:"event_id"` + IsCurrent bool `json:"is_current"` + Blockhash string `json:"blockhash"` + Blocknumber int32 `json:"blocknumber"` + Txhash string `json:"txhash"` +} + type Follow struct { Blockhash pgtype.Text `json:"blockhash"` Blocknumber pgtype.Int4 `json:"blocknumber"` @@ -1393,6 +1607,29 @@ type MutedUser struct { Blocknumber pgtype.Int4 `json:"blocknumber"` } +type MvDashboardTransactionStat struct { + Transactions24h int64 `json:"transactions_24h"` + TransactionsPrevious24h int64 `json:"transactions_previous_24h"` + Transactions7d int64 `json:"transactions_7d"` + Transactions30d int64 `json:"transactions_30d"` + TotalTransactions int64 `json:"total_transactions"` +} + +type MvDashboardTransactionType struct { + TxType string `json:"tx_type"` + TransactionCount int64 `json:"transaction_count"` +} + +// Queue of ManageEntity transactions to be forwarded to the new Core chain (audius-mainnet-v2) during genesis migration. +type NewChainQueue struct { + ID int64 `json:"id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + // Protobuf-serialized ManageEntityLegacy message. + TxData []byte `json:"tx_data"` + // Block height on the old chain where this transaction was confirmed. NULL if confirmation was not recorded (e.g. relay restart). + ConfirmedBlock pgtype.Int8 `json:"confirmed_block"` +} + type Notification struct { ID int64 `json:"id"` Specifier string `json:"specifier"` @@ -2294,6 +2531,7 @@ type TagTrackUser struct { OwnerID int32 `json:"owner_id"` } +// Collaborator credits on a track. Owner invites via track metadata (status=pending); the collaborator accepts/declines on-chain (accepted/rejected). Indexed by ETL (go-openaudio). type TrackCollaborator struct { TrackID int32 `json:"track_id"` CollaboratorUserID int32 `json:"collaborator_user_id"` @@ -2566,6 +2804,14 @@ type UserChallenge struct { CompletedAt *time.Time `json:"completed_at"` } +// Per-user inbox category (priority | general) for a direct-message chat, set via the chat.set_category RPC. Absence of a row means the chat is uncategorized for that user. +type UserConversationPreference struct { + UserID int32 `json:"user_id"` + ChatID string `json:"chat_id"` + Category string `json:"category"` + UpdatedAt time.Time `json:"updated_at"` +} + type UserDelistStatus struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UserID int32 `json:"user_id"` @@ -2662,6 +2908,8 @@ type UserRow struct { 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. CoinFlairMint pgtype.Text `json:"coin_flair_mint"` + // Timestamp of the user's most recent app-open event, updated by POST /v1/users/me/ping. + LastActiveAt pgtype.Timestamptz `json:"last_active_at"` } // Tracks some features used in user score calculation diff --git a/api/dbv1/user_chat_row.go b/api/dbv1/user_chat_row.go index 5afe732b..c17baa67 100644 --- a/api/dbv1/user_chat_row.go +++ b/api/dbv1/user_chat_row.go @@ -23,6 +23,8 @@ type UserChatRow struct { AudienceContentType *string `db:"audience_content_type" json:"audience_content_type,omitempty"` AudienceContentID *trashid.HashId `db:"audience_content_id" json:"audience_content_id,omitempty"` ChatMembers []UserChatMembers `db:"members" json:"chat_members"` + // Per-user inbox category ("priority" | "general"); nil = uncategorized. + Category *string `db:"category" json:"category"` } type UserChatMembers struct { diff --git a/api/server.go b/api/server.go index 29f577b2..a19636e4 100644 --- a/api/server.go +++ b/api/server.go @@ -790,6 +790,7 @@ func NewApiServer(config config.Config) *ApiServer { comms.Get("/chats", app.getChats) comms.Get("/chats/unread", app.getUnreadCount) + comms.Get("/chats/unread_by_category", app.getUnreadCountByCategory) comms.Get("/chats/permissions", app.getChatPermissions) comms.Get("/chats/blockers", app.getChatBlockers) comms.Get("/chats/blockees", app.getChatBlockees) diff --git a/database/seed.go b/database/seed.go index 9c0c9b28..1350c0f8 100644 --- a/database/seed.go +++ b/database/seed.go @@ -716,6 +716,12 @@ var ( "allowed": true, "updated_at": time.Now(), }, + "user_conversation_preferences": { + "user_id": nil, + "chat_id": nil, + "category": nil, + "updated_at": time.Now(), + }, "dashboard_wallet_users": { "wallet": nil, "user_id": nil, diff --git a/ddl/migrations/0240_user_conversation_preferences.sql b/ddl/migrations/0240_user_conversation_preferences.sql new file mode 100644 index 00000000..a9ada321 --- /dev/null +++ b/ddl/migrations/0240_user_conversation_preferences.sql @@ -0,0 +1,28 @@ +-- Per-user inbox category for direct-message chats. +-- +-- A user can file each of their chats into a "priority" or "general" inbox +-- (RPC chat.set_category). The choice is private to that user: the other +-- member of the same chat keeps their own row (or none). A chat with no row +-- here is "uncategorized", so clearing the category deletes the row rather +-- than storing a null. Blast pseudo-chats are never categorized. +-- +-- updated_at carries the RPC's relayed_at timestamp so that a late-arriving +-- set_category can't overwrite a newer one (same idiom as chat_permissions). +-- +-- The primary key is exactly the (user_id, chat_id) pair every reader joins +-- on, so no additional index is needed. + +BEGIN; + +CREATE TABLE IF NOT EXISTS user_conversation_preferences ( + user_id INTEGER NOT NULL, + chat_id TEXT NOT NULL, + category TEXT NOT NULL CHECK (category IN ('priority', 'general')), + updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, + PRIMARY KEY (user_id, chat_id) +); + +COMMENT ON TABLE user_conversation_preferences IS + 'Per-user inbox category (priority | general) for a direct-message chat, set via the chat.set_category RPC. Absence of a row means the chat is uncategorized for that user.'; + +COMMIT; diff --git a/sql/01_schema.sql b/sql/01_schema.sql index 93751524..1e3e1e9b 100644 --- a/sql/01_schema.sql +++ b/sql/01_schema.sql @@ -3,8 +3,8 @@ -- --- Dumped from database version 17.10 (Debian 17.10-1.pgdg13+1) --- Dumped by pg_dump version 17.10 (Debian 17.10-1.pgdg13+1) +-- Dumped from database version 17.9 (Debian 17.9-1.pgdg13+1) +-- Dumped by pg_dump version 17.9 (Debian 17.9-1.pgdg13+1) SET statement_timeout = 0; SET lock_timeout = 0; @@ -11196,6 +11196,26 @@ CREATE TABLE public.user_challenges ( ); +-- +-- Name: user_conversation_preferences; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.user_conversation_preferences ( + user_id integer NOT NULL, + chat_id text NOT NULL, + category text NOT NULL, + updated_at timestamp without time zone NOT NULL, + CONSTRAINT user_conversation_preferences_category_check CHECK ((category = ANY (ARRAY['priority'::text, 'general'::text]))) +); + + +-- +-- Name: TABLE user_conversation_preferences; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON TABLE public.user_conversation_preferences IS 'Per-user inbox category (priority | general) for a direct-message chat, set via the chat.set_category RPC. Absence of a row means the chat is uncategorized for that user.'; + + -- -- Name: user_delist_statuses; Type: TABLE; Schema: public; Owner: - -- @@ -13204,6 +13224,14 @@ ALTER TABLE ONLY public.user_challenges ADD CONSTRAINT user_challenges_pkey PRIMARY KEY (challenge_id, specifier); +-- +-- Name: user_conversation_preferences user_conversation_preferences_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_conversation_preferences + ADD CONSTRAINT user_conversation_preferences_pkey PRIMARY KEY (user_id, chat_id); + + -- -- Name: user_delist_statuses user_delist_statuses_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -14517,6 +14545,13 @@ CREATE INDEX ix_reactions_reacted_to_reaction_type ON public.reactions USING btr CREATE INDEX ix_subscriptions_blocknumber ON public.subscriptions USING btree (blocknumber); +-- +-- Name: ix_subscriptions_entity_type_entity_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX ix_subscriptions_entity_type_entity_id ON public.subscriptions USING btree (entity_type, entity_id); + + -- -- Name: ix_subscriptions_user_id; Type: INDEX; Schema: public; Owner: - -- @@ -14832,6 +14867,13 @@ CREATE INDEX saves_item_idx ON public.saves USING btree (save_item_id, save_type CREATE INDEX saves_new_blocknumber_idx ON public.saves USING btree (blocknumber); +-- +-- Name: saves_user_created_at_active_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX saves_user_created_at_active_idx ON public.saves USING btree (user_id, created_at DESC) INCLUDE (save_type, save_item_id) WHERE (is_delete = false); + + -- -- Name: saves_user_idx; Type: INDEX; Schema: public; Owner: - -- @@ -15228,7 +15270,7 @@ COMMENT ON INDEX public.sol_user_balances_mint_user_id_idx IS 'Index for quick a -- Name: subscriptions_current_uniq_idx; Type: INDEX; Schema: public; Owner: - -- -CREATE UNIQUE INDEX subscriptions_current_uniq_idx ON public.subscriptions USING btree (subscriber_id, user_id) WHERE (is_current = true); +CREATE UNIQUE INDEX subscriptions_current_uniq_idx ON public.subscriptions USING btree (subscriber_id, user_id, entity_type) WHERE (is_current = true); -- diff --git a/sql/03_migration_tracker.sql b/sql/03_migration_tracker.sql index 7dfc0bff..b0141a51 100644 --- a/sql/03_migration_tracker.sql +++ b/sql/03_migration_tracker.sql @@ -3,8 +3,8 @@ -- --- Dumped from database version 17.10 (Debian 17.10-1.pgdg13+1) --- Dumped by pg_dump version 17.10 (Debian 17.10-1.pgdg13+1) +-- Dumped from database version 17.9 (Debian 17.9-1.pgdg13+1) +-- Dumped by pg_dump version 17.9 (Debian 17.9-1.pgdg13+1) SET statement_timeout = 0; SET lock_timeout = 0; @@ -114,7 +114,6 @@ functions/handle_comms_rpc_log.sql bca8170b77a97b521b050f49873d43d4 2026-05-27 0 functions/handle_dbc_pools.sql 5d8727fa203bb5f204868f4209a2022a 2026-05-27 00:22:36.618827+00 functions/handle_manager_request.sql 426004c1b9ac2be9e5721afb580679be 2026-05-27 00:22:36.848838+00 functions/handle_play.sql c710d1ef4805d7f99817baffc6ed8e25 2026-05-27 00:22:36.924069+00 -functions/handle_playlist.sql 4b338726d86db94fb3339e8407968cdf 2026-05-27 00:22:36.987976+00 functions/handle_playlist_track.sql bbb4dce9244617aa2d6580aae05d154a 2026-05-27 00:22:37.057245+00 functions/handle_reaction.sql 679796675e687b45288c517c568692d4 2026-05-27 00:22:37.13432+00 functions/handle_share.sql 84efa350bfd7f5501ccd34a93f6207a6 2026-05-27 00:22:37.371581+00 @@ -159,13 +158,13 @@ functions/handle_event.sql f15d9cc1838fa327b5df7e6b10c5ad7c 2026-07-28 05:42:28. functions/handle_follow.sql cf954862ef38daf93740ca1a88f24863 2026-07-28 05:42:28.792892+00 functions/handle_repost.sql e7cfd188b5aec2b01584dc8db1e9bc00 2026-07-28 05:42:28.962664+00 functions/handle_save.sql 05422848c57572704f78f27171e94058 2026-07-28 05:42:29.039008+00 -functions/handle_track.sql c2d4c5674b0cb1db907ad625fd957c91 2026-07-28 05:42:29.145055+00 functions/notify_on_row.sql a326d476636de01dd939047526b0cb92 2026-07-28 05:42:29.345724+00 preflight/0001_initial_block.sql 6cc3c0833c195a1104bed5bf849c0266 2026-07-28 05:42:29.588508+00 functions/handle_comment_reaction.sql 8153e3cdb922265857b6beaf20d29733 2026-05-30 01:37:22.856663+00 functions/handle_user_challenges.sql 202037a6ec14955885a479648e2cc57a 2026-08-05 00:50:26.86158+00 views/artist_coin_prices.sql fbfb4b530235c4b95f851bf5be2a063d 2026-08-05 00:50:27.089199+00 functions/handle_comment_thread.sql 6eb74eb92cf3a01421498df96c6832f3 2026-05-30 01:37:22.955997+00 +functions/handle_track.sql e7f09963e58d4462c8a43dbd4b11a275 2026-09-10 19:20:26.421471+00 functions/handle_eth_wallet_balance_change.sql 3e31160b4bc55e951d9dfa4d994c180b 2026-05-30 01:37:23.054573+00 functions/handle_fan_club_text_post.sql 531bf682bcfd67c6866faf8ccdf7603b 2026-05-30 01:37:23.160142+00 functions/handle_tastemaker.sql 04690b53bd094a59717ef3a5b5d2c0a0 2026-05-30 01:37:23.34542+00 @@ -215,6 +214,10 @@ migrations/0235_drop_coin_stats_shadow.sql ecbd5cd4d2edd02bbb86d760c9768388 2026 migrations/0236_saves_reposts_album_to_playlist.sql 5bd5036831dbfa656352dfd937c3fe5c 2026-08-05 00:50:26.077564+00 migrations/0237_users_one_current_row_backfill.sql b48ab562bc1ab92d12a17795a59cdf84 2026-08-05 00:50:26.167093+00 functions/handle_challenge_disbursements.sql 32db00f1ecfcfbda0094c0a5e1e6e300 2026-08-05 00:50:26.417396+00 +migrations/0238_backfill_track_playlist_reverse_index.sql 876450b97109942a25da304c101004fe 2026-09-10 19:20:25.957168+00 +migrations/0239_saves_user_created_at_idx.sql 8a73b4eab0aa290ab720a706ec06e249 2026-09-10 19:20:26.027654+00 +migrations/0240_user_conversation_preferences.sql 76aa7750514e2a1c74ed3c31e1b55dcd 2026-09-10 19:20:26.092795+00 +functions/handle_playlist.sql 17abfed3041b8039dde8dfbfd025c5ff 2026-09-10 19:20:26.309846+00 \.