From 75428fd35bfe06ae2baba851a8ebcf4d859033c6 Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Wed, 23 Sep 2026 16:43:48 -0700 Subject: [PATCH 1/4] fix(notifications): send weekly rotation browser push to users without devices The browser push was behind shouldSendPushNotification, which requires a mobile device, so web-only listeners never got it. Send it first, then gate only the mobile push. Removes the devices.length check that could never hit. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/mappings/weeklyRotation.test.ts | 9 ++++++++- .../mappers/weeklyRotation.ts | 17 ++++++++--------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/apps/notifications/src/__tests__/mappings/weeklyRotation.test.ts b/apps/notifications/src/__tests__/mappings/weeklyRotation.test.ts index 1d71664..8d01eca 100644 --- a/apps/notifications/src/__tests__/mappings/weeklyRotation.test.ts +++ b/apps/notifications/src/__tests__/mappings/weeklyRotation.test.ts @@ -81,7 +81,7 @@ describe('Weekly Rotation Notification', () => { ) }) - test('Does not push to a user with no devices', async () => { + test('Sends a browser push but no mobile push to a user with no devices', async () => { await createUsers(processor.discoveryDB, [{ user_id: 2 }]) await insertNotifications(processor.discoveryDB, [ @@ -101,5 +101,12 @@ describe('Weekly Rotation Notification', () => { await processor.appNotificationsProcessor.process(pending.appNotifications) expect(sendPushNotificationSpy).not.toHaveBeenCalled() + expect(sendBrowserNotificationSpy).toHaveBeenCalledWith( + true, + expect.any(Object), + 2, + weeklyRotationMessages.title, + weeklyRotationMessages.body + ) }) }) diff --git a/apps/notifications/src/processNotifications/mappers/weeklyRotation.ts b/apps/notifications/src/processNotifications/mappers/weeklyRotation.ts index 54313a6..b4815ed 100644 --- a/apps/notifications/src/processNotifications/mappers/weeklyRotation.ts +++ b/apps/notifications/src/processNotifications/mappers/weeklyRotation.ts @@ -56,6 +56,14 @@ export class WeeklyRotation extends BaseNotification From ed84e1d73289c03c198e3255de07e6bb1598829a Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Wed, 23 Sep 2026 16:44:44 -0700 Subject: [PATCH 2/4] fix(trending-challenge-rewards): escape track titles in Slack links A title containing >, < or & broke the link markup in the digest. Escape them per Slack's mrkdwn rules. Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/trending.test.ts | 14 ++++++++++++++ apps/trending-challenge-rewards/src/trending.ts | 8 +++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/trending-challenge-rewards/src/__tests__/trending.test.ts b/apps/trending-challenge-rewards/src/__tests__/trending.test.ts index b217534..7478255 100644 --- a/apps/trending-challenge-rewards/src/__tests__/trending.test.ts +++ b/apps/trending-challenge-rewards/src/__tests__/trending.test.ts @@ -261,6 +261,20 @@ describe('composeTrackLinks', () => { expect(out.indexOf('Track One')).toBeLessThan(out.indexOf('Track Two')) }) + it('escapes slack control characters in track titles', () => { + const out = composeTrackLinks('Top 10 Trending Tracks 🔥', [ + { + handle: '@first', + rank: 1, + title: 'Up > Down & ', + url: 'https://audius.co/a/up-down' + } + ]) + expect(out).toContain( + '' + ) + }) + it('falls back to a placeholder when the track link is missing', () => { const out = composeTrackLinks('Top 10 Trending Underground 🎵', [ { handle: '@first', rank: 1 } diff --git a/apps/trending-challenge-rewards/src/trending.ts b/apps/trending-challenge-rewards/src/trending.ts index 2eb140e..e7d6f9c 100644 --- a/apps/trending-challenge-rewards/src/trending.ts +++ b/apps/trending-challenge-rewards/src/trending.ts @@ -277,6 +277,10 @@ export const composeTweet = ( return '```\n' + `${title} (${week})` + newLine + rows + '```' } +// Slack mrkdwn requires &, < and > to be escaped in message text. +const escapeSlack = (text: string): string => + text.replace(/&/g, '&').replace(//g, '>') + // Companion to the tweet block: the same winners with the track that actually // won and a clickable link, so artists with several trending tracks can be // told apart. Kept outside the code fence so Slack renders the links. @@ -288,7 +292,9 @@ export const composeTrackLinks = ( if (entry.url === undefined || entry.title === undefined) { return `${entry.rank}. ${entry.handle} — _track link unavailable_` } - return `${entry.rank}. ${entry.handle} — <${entry.url}|${entry.title}>` + return `${entry.rank}. ${entry.handle} — <${entry.url}|${escapeSlack( + entry.title + )}>` }) return [`*${title}*`, ...lines].join('\n') } From 6d552fd1c9063e29ee0e10ae3bd317011d7b9045 Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Wed, 23 Sep 2026 16:45:46 -0700 Subject: [PATCH 3/4] chore: tighten comments in trending digest and notifications Shorten comments added with the trending digest, General inbox and weekly rotation changes, and use a plain hyphen in the digest's track lines. Co-Authored-By: Claude Opus 5.5 --- .../processNotifications/mappers/weeklyRotation.ts | 14 +++++--------- apps/notifications/src/tasks/dmNotifications.ts | 9 +++------ .../src/__tests__/trending.test.ts | 8 ++++---- apps/trending-challenge-rewards/src/trending.ts | 9 ++++----- 4 files changed, 16 insertions(+), 24 deletions(-) diff --git a/apps/notifications/src/processNotifications/mappers/weeklyRotation.ts b/apps/notifications/src/processNotifications/mappers/weeklyRotation.ts index b4815ed..2583f85 100644 --- a/apps/notifications/src/processNotifications/mappers/weeklyRotation.ts +++ b/apps/notifications/src/processNotifications/mappers/weeklyRotation.ts @@ -20,16 +20,12 @@ export const weeklyRotationMessages = { } /** - * Announces the Wednesday Weekly Rotation rollover. + * Push for the Wednesday Weekly Rotation rollover. Rows are written once per + * listener per period by the api's WeeklyRotationNotificationsJob + * (group_id `weekly_rotation::`). * - * Rows are written once per listener per period by the api repo's - * WeeklyRotationNotificationsJob (group_id `weekly_rotation::`). - * There is no entity to link: the push and the in-app tile both open the - * listener's own mix, which the client fetches on demand. - * - * Deliberately no rich image. The og collage for a mix is rendered from the - * mix itself, so attaching it would make every device that receives the push - * compute its owner's rotation at once. + * No rich image: the og collage is rendered from the mix, so attaching it + * would compute a mix per recipient. */ export class WeeklyRotation extends BaseNotification { receiverUserId: number diff --git a/apps/notifications/src/tasks/dmNotifications.ts b/apps/notifications/src/tasks/dmNotifications.ts index d580b86..b3dd4e6 100644 --- a/apps/notifications/src/tasks/dmNotifications.ts +++ b/apps/notifications/src/tasks/dmNotifications.ts @@ -247,12 +247,9 @@ export async function getNewBlasts( } } -// Chats the receiver has filed into their General inbox (chat.set_category) -// are silent: the message still lands in the inbox and lights the General tab -// dot in the clients, but no push or browser notification is sent. A chat with -// no preference row (uncategorized) or filed as Priority notifies as normal. -// The preference is per receiver, so the same chat can be silent for one -// member and loud for the other. +// Chats the receiver filed as General (chat.set_category) get no push or +// browser notification. Uncategorized and Priority chats notify as usual. +// The category is per receiver. export const generalInboxKey = (receiverUserId: number, chatId: string) => `${receiverUserId}:${chatId}` diff --git a/apps/trending-challenge-rewards/src/__tests__/trending.test.ts b/apps/trending-challenge-rewards/src/__tests__/trending.test.ts index 7478255..0b23760 100644 --- a/apps/trending-challenge-rewards/src/__tests__/trending.test.ts +++ b/apps/trending-challenge-rewards/src/__tests__/trending.test.ts @@ -135,7 +135,7 @@ describe('queryHandles', () => { twitter: '@third_prefixed', instagram: '@insta_prefixed' }) - // users missing from discovery keep the synthetic fallback, no instagram + // users missing from discovery fall back to @/user- with no instagram expect(handles.get(4)).toEqual({ twitter: '@/user-4' }) }) }) @@ -202,7 +202,7 @@ describe('queryTrackLinks', () => { title: 'First Track', url: 'https://audius.co/artistOne/first-track' }) - // rows without a current route are skipped rather than linked to nowhere + // rows without a current route are skipped expect(links.get(11)).toBeUndefined() }) @@ -256,7 +256,7 @@ describe('composeTrackLinks', () => { expect(out).toContain('*Top 10 Trending Tracks 🔥*') expect(out).toContain( - '1. @first — ' + '1. @first - ' ) expect(out.indexOf('Track One')).toBeLessThan(out.indexOf('Track Two')) }) @@ -279,6 +279,6 @@ describe('composeTrackLinks', () => { const out = composeTrackLinks('Top 10 Trending Underground 🎵', [ { handle: '@first', rank: 1 } ]) - expect(out).toContain('1. @first — _track link unavailable_') + expect(out).toContain('1. @first - _track link unavailable_') }) }) diff --git a/apps/trending-challenge-rewards/src/trending.ts b/apps/trending-challenge-rewards/src/trending.ts index e7d6f9c..72aade8 100644 --- a/apps/trending-challenge-rewards/src/trending.ts +++ b/apps/trending-challenge-rewards/src/trending.ts @@ -281,18 +281,17 @@ export const composeTweet = ( const escapeSlack = (text: string): string => text.replace(/&/g, '&').replace(//g, '>') -// Companion to the tweet block: the same winners with the track that actually -// won and a clickable link, so artists with several trending tracks can be -// told apart. Kept outside the code fence so Slack renders the links. +// Winners with their track title and link. Outside the code block so Slack +// renders the links. export const composeTrackLinks = ( title: string, entries: TrendingEntry[] ): string => { const lines = byRank(entries).map((entry) => { if (entry.url === undefined || entry.title === undefined) { - return `${entry.rank}. ${entry.handle} — _track link unavailable_` + return `${entry.rank}. ${entry.handle} - _track link unavailable_` } - return `${entry.rank}. ${entry.handle} — <${entry.url}|${escapeSlack( + return `${entry.rank}. ${entry.handle} - <${entry.url}|${escapeSlack( entry.title )}>` }) From 7604157979d30f93f9e2cb360f2385d8ba6218d8 Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Wed, 23 Sep 2026 16:45:47 -0700 Subject: [PATCH 4/4] chore(notifications): match weekly rotation push body to in-app copy Co-Authored-By: Claude Opus 5.5 --- .../src/processNotifications/mappers/weeklyRotation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/notifications/src/processNotifications/mappers/weeklyRotation.ts b/apps/notifications/src/processNotifications/mappers/weeklyRotation.ts index 2583f85..d067661 100644 --- a/apps/notifications/src/processNotifications/mappers/weeklyRotation.ts +++ b/apps/notifications/src/processNotifications/mappers/weeklyRotation.ts @@ -16,7 +16,7 @@ type WeeklyRotationNotificationRow = Omit & { export const weeklyRotationMessages = { title: '🎧 Your Weekly Rotation Is Ready', - body: 'A fresh mix of tracks picked just for you. Give it a spin before it rotates next Wednesday.' + body: 'A fresh mix of tracks picked just for you. Updates every Wednesday.' } /**