From 7400c31bb924bf2dd8198ddc781fa44864d93917 Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Wed, 23 Sep 2026 16:45:32 -0700 Subject: [PATCH 1/2] fix(comms): hide blasts older than the sender's inbox settings change GET /comms/blasts and chat creation still listed blasts sent before the artist closed their inbox, so recipients saw a pending blast they could not reply to. Apply the same cutoff hasNewBlastFromUser uses. Co-Authored-By: Claude Opus 5.5 --- api/comms/chat.go | 6 ++++++ api/comms/chat_inbox_closed_test.go | 17 +++++++++++++--- api/comms_blasts.go | 6 ++++++ api/comms_blasts_test.go | 31 +++++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 3 deletions(-) diff --git a/api/comms/chat.go b/api/comms/chat.go index c02120d4..665c5b86 100644 --- a/api/comms/chat.go +++ b/api/comms/chat.go @@ -448,6 +448,12 @@ func getNewBlasts(tx dbv1.DBTX, ctx context.Context, arg getNewBlastsParams) ([] ) SELECT * FROM all_new WHERE created_at > (select t from last_permission_change) + -- a blast is dropped once the sender changes their own inbox settings + AND created_at > ( + SELECT COALESCE(MAX(cp.updated_at), to_timestamp(0)) + FROM chat_permissions cp + WHERE cp.user_id = all_new.from_user_id + ) AND chat_allowed(from_user_id, @user_id) ORDER BY created_at ;` diff --git a/api/comms/chat_inbox_closed_test.go b/api/comms/chat_inbox_closed_test.go index 6c9251e6..c5a63e0d 100644 --- a/api/comms/chat_inbox_closed_test.go +++ b/api/comms/chat_inbox_closed_test.go @@ -59,6 +59,15 @@ func TestChatBlastThenCloseInbox(t *testing.T) { assert.ErrorContains(t, err, "Not permitted to send messages to this user") } } + pendingBlastIds := func(userId int32) []string { + blasts, err := getNewBlasts(pool, ctx, getNewBlastsParams{UserID: userId}) + require.NoError(t, err) + ids := []string{} + for _, b := range blasts { + ids = append(ids, b.BlastID) + } + return ids + } upgrade := func(follower int32, ts time.Time) string { chatId := trashid.ChatID(int(follower), 1) err := chatCreate(pool, ctx, follower, ts, ChatCreateRPCParams{ @@ -95,11 +104,12 @@ func TestChatBlastThenCloseInbox(t *testing.T) { // artist closes their inbox require.NoError(t, chatSetPermissions(pool, ctx, 1, ChatPermissionAll, []ChatPermission{ChatPermissionNone}, boolPtr(true), t3)) - // 201 can no longer start a thread off the old blast + // 201 can no longer start a thread off the old blast, and no longer sees it as pending assertChatCreateAllowed(t, ctx, validator, 201, 1, false) assert.False(t, chatAllowed(201, 1)) + assert.Empty(t, pendingBlastIds(201)) - // 202's thread holds nothing but the blast seed, so it grants no reply rights + // 202's thread only has the blast seed, so 202 can't reply assertMessageAllowed(202, chatId_202, false) assert.False(t, chatAllowed(202, 1)) @@ -123,9 +133,10 @@ func TestChatBlastThenCloseInbox(t *testing.T) { require.NoError(t, err) assertChatCreateAllowed(t, ctx, validator, 201, 1, true) + assert.Equal(t, []string{"b_closed"}, pendingBlastIds(201)) assert.True(t, chatAllowed(202, 1), "new blast fanned into 202's thread re-opens replies") - // closing the inbox once more after that blast shuts the door again + // closing the inbox again blocks replies to the second blast require.NoError(t, chatSetPermissions(pool, ctx, 1, ChatPermissionAll, []ChatPermission{ChatPermissionNone}, boolPtr(true), t5)) assertChatCreateAllowed(t, ctx, validator, 201, 1, false) assert.False(t, chatAllowed(202, 1)) diff --git a/api/comms_blasts.go b/api/comms_blasts.go index cd7639ff..d254584e 100644 --- a/api/comms_blasts.go +++ b/api/comms_blasts.go @@ -108,6 +108,12 @@ func (app *ApiServer) getNewBlasts(c *fiber.Ctx) error { ) SELECT * FROM all_new WHERE created_at > (select t from last_permission_change) + -- a blast is dropped once the sender changes their own inbox settings + AND created_at > ( + SELECT COALESCE(MAX(cp.updated_at), to_timestamp(0)) + FROM chat_permissions cp + WHERE cp.user_id = all_new.from_user_id + ) AND chat_allowed(from_user_id, @user_id) ORDER BY created_at ;` diff --git a/api/comms_blasts_test.go b/api/comms_blasts_test.go index 2374fa52..63ac9393 100644 --- a/api/comms_blasts_test.go +++ b/api/comms_blasts_test.go @@ -7,6 +7,7 @@ import ( "api.audius.co/database" "api.audius.co/trashid" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestGetNewBlasts(t *testing.T) { @@ -540,6 +541,36 @@ func TestGetNewBlastsWithPermissions(t *testing.T) { }) } +func TestGetNewBlastsAfterSenderClosesInbox(t *testing.T) { + app := emptyTestApp(t) + + // chat_permissions.updated_at is timestamp without time zone, written as UTC + now := time.Now().UTC() + fixtures := database.FixtureMap{ + "users": { + {"user_id": 1, "handle": "artist1", "wallet": "0x7d273271690538cf855e5b3002a0dd8c154bb060", "created_at": now.Add(-time.Hour), "updated_at": now.Add(-time.Hour), "is_current": true}, + {"user_id": 2, "handle": "fan1", "wallet": "0xc3d1d41e6872ffbd15c473d14fc3a9250be5b5e0", "created_at": now.Add(-time.Hour), "updated_at": now.Add(-time.Hour), "is_current": true}, + }, + "follows": { + {"follower_user_id": 2, "followee_user_id": 1, "created_at": now.Add(-time.Hour), "is_current": true, "is_delete": false}, + }, + "chat_blast": { + {"blast_id": "blast_before_close", "from_user_id": 1, "audience": "follower_audience", "plaintext": "before close", "created_at": now.Add(-10 * time.Minute)}, + {"blast_id": "blast_after_close", "from_user_id": 1, "audience": "follower_audience", "plaintext": "after close", "created_at": now.Add(-2 * time.Minute)}, + }, + // the artist (sender) closed their inbox between the two blasts + "chat_permissions": { + {"user_id": 1, "permits": "none", "allowed": true, "updated_at": now.Add(-5 * time.Minute)}, + }, + } + database.Seed(app.pool.Replicas[0], fixtures) + + status, body := testGetWithWallet(t, app, "/comms/blasts", "0xc3d1d41e6872ffbd15c473d14fc3a9250be5b5e0") + require.Equal(t, 200, status) + assert.Contains(t, string(body), "blast_after_close") + assert.NotContains(t, string(body), "blast_before_close") +} + func TestGetNewBlastsWithExistingChats(t *testing.T) { app := emptyTestApp(t) From 96a40690b3fac1615d6df8e4d466552612c8890f Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Wed, 23 Sep 2026 16:46:15 -0700 Subject: [PATCH 2/2] chore(api): shorten comments on weekly rotation period, pool window and chat_allowed Co-Authored-By: Claude Opus 5.5 --- api/v1_challenges_info.go | 4 +--- api/v1_challenges_info_test.go | 2 +- ddl/functions/chat_allowed.sql | 6 ++---- sql/01_schema.sql | 6 ++---- weeklyrotation/period.go | 9 +++------ 5 files changed, 9 insertions(+), 18 deletions(-) diff --git a/api/v1_challenges_info.go b/api/v1_challenges_info.go index b5571ce2..d093ba37 100644 --- a/api/v1_challenges_info.go +++ b/api/v1_challenges_info.go @@ -30,9 +30,7 @@ func getWeeklyPoolWindowStart() time.Time { // weeklyPoolWindowStartAt returns the most recent Monday 16:00 UTC at or // before `now`: the instant the current weekly reward pool opened. // -// Go numbers Sunday as 0, so the naive "subtract Weekday, add one" walk to -// Monday landed on the *following* Monday all day Sunday, putting the window -// start in the future and reporting the full pool as remaining every Sunday. +// Go's Weekday has Sunday=0, so it is remapped to Monday=0..Sunday=6. func weeklyPoolWindowStartAt(now time.Time) time.Time { now = now.UTC() daysSinceMonday := (int(now.Weekday()) + 6) % 7 // Monday=0 ... Sunday=6 diff --git a/api/v1_challenges_info_test.go b/api/v1_challenges_info_test.go index d54895fa..0c991f0f 100644 --- a/api/v1_challenges_info_test.go +++ b/api/v1_challenges_info_test.go @@ -90,7 +90,7 @@ func TestWeeklyPoolWindowStartAt(t *testing.T) { {"Monday at 16:00 opens the week", thisMonday, thisMonday}, {"Wednesday", utc(2026, time.September, 9, 12), thisMonday}, {"Saturday", utc(2026, time.September, 12, 23), thisMonday}, - {"Sunday (Go weekday 0) is the end of the week, not the start of the next", utc(2026, time.September, 13, 21), thisMonday}, + {"Sunday", utc(2026, time.September, 13, 21), thisMonday}, {"Sunday just before the next Monday", utc(2026, time.September, 13, 23), thisMonday}, } for _, tc := range cases { diff --git a/ddl/functions/chat_allowed.sql b/ddl/functions/chat_allowed.sql index 40b01108..fab4e958 100644 --- a/ddl/functions/chat_allowed.sql +++ b/ddl/functions/chat_allowed.sql @@ -26,10 +26,8 @@ BEGIN RETURN TRUE; END IF; - -- existing chat takes priority over permissions. - -- A blast message only counts if the blast is newer than to_user's most - -- recent inbox settings change: otherwise an artist who blasts and then - -- closes their inbox would still be reachable by every blast recipient. + -- existing chat takes priority over permissions; blast messages only count + -- if newer than to_user's last inbox settings change SELECT COUNT(*) > 0 INTO can_message FROM chat_member member_a JOIN chat_member member_b USING (chat_id) diff --git a/sql/01_schema.sql b/sql/01_schema.sql index a21cf0dc..d4dd633a 100644 --- a/sql/01_schema.sql +++ b/sql/01_schema.sql @@ -1203,10 +1203,8 @@ BEGIN RETURN TRUE; END IF; - -- existing chat takes priority over permissions. - -- A blast message only counts if the blast is newer than to_user's most - -- recent inbox settings change: otherwise an artist who blasts and then - -- closes their inbox would still be reachable by every blast recipient. + -- existing chat takes priority over permissions; blast messages only count + -- if newer than to_user's last inbox settings change SELECT COUNT(*) > 0 INTO can_message FROM chat_member member_a JOIN chat_member member_b USING (chat_id) diff --git a/weeklyrotation/period.go b/weeklyrotation/period.go index c541d909..f2386d82 100644 --- a/weeklyrotation/period.go +++ b/weeklyrotation/period.go @@ -14,12 +14,9 @@ import ( "time" ) -// RolloverOffsetDays is how far the period boundary sits after the ISO -// week's Monday. The mix rolls over on Wednesday 00:00 UTC, not at the ISO -// week boundary: Monday already belongs to the other weekly surfaces and -// Friday is Spotify's day. Expressed as an offset so the period is still -// identified by an (iso_year, iso_week) pair everywhere -- cache keys, the -// deterministic seed, share links, notification group ids. +// RolloverOffsetDays shifts the period start from the ISO Monday to Wednesday +// 00:00 UTC. Periods are still keyed by (iso_year, iso_week) in cache keys, +// the seed, share links and notification group ids. const RolloverOffsetDays = 2 // Period returns the (ISO year, ISO week) pair that identifies the period