From 8bf957551c44190d23964f89455dde73035829e1 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 2 Oct 2026 17:56:56 -0400 Subject: [PATCH] feature: bell switch for every per-player notification type Every per-player type gets an in-app key except a player's own account and safety notices (locked) and the push-only rings and chat. The push preference list now carries each category's types with how the bell treats them, so the web can list every kind without its own copy of the mapping. Organizer match alerts go through notifyPlayers so their new bell switch is honoured. --- .../notifications.service.spec.ts | 112 +++++++++++++++ src/notifications/notifications.service.ts | 47 +++---- .../notification-categories.spec.ts | 130 ++++++++++++++++++ .../preferences/notification-categories.ts | 100 +++++++++++--- .../notification-preferences.service.spec.ts | 67 +++++++++ .../notification-preferences.service.ts | 8 +- 6 files changed, 418 insertions(+), 46 deletions(-) create mode 100644 src/notifications/preferences/notification-preferences.service.spec.ts diff --git a/src/notifications/notifications.service.spec.ts b/src/notifications/notifications.service.spec.ts index ced96f80..e90bf347 100644 --- a/src/notifications/notifications.service.spec.ts +++ b/src/notifications/notifications.service.spec.ts @@ -538,3 +538,115 @@ describe("latestTitle", () => { ).resolves.toBeNull(); }); }); + +// Match alerts reach a match's organizers by steam id, so the bell switch for +// them has to be honoured at insert like every other per-player type. +describe("match alerts to organizers", () => { + const ORGANIZER = "76561198000000001"; + const CO_ORGANIZER = "76561198000000002"; + + let hasura: { query: jest.Mock; mutation: jest.Mock }; + let preferences: { filterInAppRecipients: jest.Mock }; + let pushNotifications: { + filterSubscribed: jest.Mock; + claimFanOut: jest.Mock; + }; + let service: NotificationsService; + + const organizedBy = (tournament: Record | null) => + hasura.query.mockImplementation(async (query: any) => { + if (query.tournament_brackets) { + return { + tournament_brackets: tournament ? [{ stage: { tournament } }] : [], + }; + } + if (query.matches_by_pk) { + return { matches_by_pk: { organizer_steam_id: ORGANIZER } }; + } + return { settings_by_pk: null }; + }); + + const writtenRows = () => + hasura.mutation.mock.calls.flatMap(([mutation]: [any]) => + mutation.insert_notifications + ? mutation.insert_notifications.__args.objects + : [mutation.insert_notifications_one.__args.object], + ); + + beforeEach(() => { + hasura = { + query: jest.fn(), + mutation: jest.fn(async (mutation: any) => ({ + insert_notifications: { + returning: (mutation.insert_notifications?.__args.objects ?? []).map( + (_: unknown, index: number) => ({ id: `row-${index}` }), + ), + }, + insert_notifications_one: { id: "row" }, + })), + }; + preferences = { + filterInAppRecipients: jest.fn(async (): Promise => []), + }; + pushNotifications = { + filterSubscribed: jest.fn(async (): Promise => []), + claimFanOut: jest.fn().mockResolvedValue(undefined), + }; + + service = new NotificationsService( + hasura as any, + { query: jest.fn().mockResolvedValue([]) } as any, + { log: jest.fn(), warn: jest.fn(), error: jest.fn() } as any, + { get: () => ({ webDomain: "https://5stack.gg" }) } as any, + preferences as any, + pushNotifications as any, + { add: jest.fn() } as any, + { add: jest.fn().mockResolvedValue({}) } as any, + ); + }); + + it.each([ + ["a paused map", "sendMatchMapPauseNotification"], + ["a match waiting for a server", "sendMatchWaitingForServerNotification"], + ] as const)( + "keeps %s out of the bell of an organizer who muted it", + async (_, send) => { + organizedBy(null); + + await service[send]("match-1"); + + expect(preferences.filterInAppRecipients).toHaveBeenCalledWith( + "MatchStatusChange", + [ORGANIZER], + ); + expect(writtenRows().filter((row: any) => row.steam_id)).toEqual([]); + expect(writtenRows().map((row: any) => row.role)).toEqual([ + "match_organizer", + ]); + }, + ); + + it("asks every tournament organizer's bell, and writes only for those listening", async () => { + organizedBy({ + id: "tournament-1", + name: "Autumn Cup", + organizer_steam_id: ORGANIZER, + organizers: [{ steam_id: CO_ORGANIZER }], + discord_notify_MapPaused: false, + }); + preferences.filterInAppRecipients.mockResolvedValue([CO_ORGANIZER]); + + await service.sendMatchMapPauseNotification("match-1"); + + expect(preferences.filterInAppRecipients).toHaveBeenCalledWith( + "MatchStatusChange", + [ORGANIZER, CO_ORGANIZER], + ); + expect( + writtenRows().map((row: any) => [row.steam_id ?? null, row.role]), + ).toEqual([ + [CO_ORGANIZER, "tournament_organizer"], + [null, "administrator"], + ]); + }); +}); diff --git a/src/notifications/notifications.service.ts b/src/notifications/notifications.service.ts index 6fd270ee..cc2eda44 100644 --- a/src/notifications/notifications.service.ts +++ b/src/notifications/notifications.service.ts @@ -925,13 +925,12 @@ export class NotificationsService { } if (matches_by_pk.organizer_steam_id) { - await this.insertNotification({ - type: "MatchStatusChange", + await this.notifyPlayers("MatchStatusChange", { title, message, - steam_id: matches_by_pk.organizer_steam_id, role: "user", entity_id: matchId, + steamIds: [String(matches_by_pk.organizer_steam_id)], }); } @@ -968,16 +967,13 @@ export class NotificationsService { organizerSteamIds.add(String(org.steam_id)); } - for (const steamId of organizerSteamIds) { - await this.insertNotification({ - type: "MatchStatusChange", - title, - message, - steam_id: steamId, - role: "tournament_organizer", - entity_id: matchId, - }); - } + await this.notifyPlayers("MatchStatusChange", { + title, + message, + role: "tournament_organizer", + entity_id: matchId, + steamIds: Array.from(organizerSteamIds), + }); await this.insertNotification({ type: "MatchStatusChange", @@ -1056,13 +1052,12 @@ export class NotificationsService { } if (matches_by_pk.organizer_steam_id) { - await this.insertNotification({ - type: "MatchStatusChange", + await this.notifyPlayers("MatchStatusChange", { title, message, - steam_id: matches_by_pk.organizer_steam_id, role: "user", entity_id: matchId, + steamIds: [String(matches_by_pk.organizer_steam_id)], }); } @@ -1100,16 +1095,13 @@ export class NotificationsService { organizerSteamIds.add(String(org.steam_id)); } - for (const steamId of organizerSteamIds) { - await this.insertNotification({ - type: "MatchStatusChange", - title, - message, - steam_id: steamId, - role: "tournament_organizer", - entity_id: matchId, - }); - } + await this.notifyPlayers("MatchStatusChange", { + title, + message, + role: "tournament_organizer", + entity_id: matchId, + steamIds: Array.from(organizerSteamIds), + }); await this.insertNotification({ type: "MatchStatusChange", @@ -1141,13 +1133,14 @@ export class NotificationsService { } } + // Role broadcasts only: a row for one player goes through notifyPlayers, + // which is where their bell preference is applied. private async insertNotification(notification: { type: e_notification_types_enum; title: string; message: string; entity_id?: string; role: e_player_roles_enum; - steam_id?: string; deletable?: boolean; }) { await this.hasura.mutation({ diff --git a/src/notifications/preferences/notification-categories.spec.ts b/src/notifications/preferences/notification-categories.spec.ts index 8020ce53..68478bec 100644 --- a/src/notifications/preferences/notification-categories.spec.ts +++ b/src/notifications/preferences/notification-categories.spec.ts @@ -6,6 +6,9 @@ import { IN_APP_KEYS, pushCategoryForType, inAppKeyForType, + isKnownKey, + bellControlForType, + typesForCategory, } from "./notification-categories"; const HASURA_DIR = join(__dirname, "../../../hasura"); @@ -37,6 +40,29 @@ const notificationTypesInTree = (): string[] => { return [...types].sort(); }; +// The bell may mute any notice about the player except their own name-change +// decisions, sanctions and warnings. +const ACCOUNT_AND_SAFETY = [ + "NameChangeApproved", + "NameChangeDenied", + "PlayerSanctioned", + "PlayerWarning", +]; + +const NEVER_IN_THE_BELL = [ + "MatchFound", + "AdminCall", + "ChatMessage", + "MatchChatMessage", +]; + +const staffTypes = () => + new Set( + PUSH_KEYS.filter((entry) => entry.adminOnly).flatMap( + (entry) => PUSH_CATEGORIES[entry.key], + ), + ); + describe("notification categories", () => { const types = notificationTypesInTree(); @@ -149,4 +175,108 @@ describe("notification categories", () => { expect(inAppKeyForType("GameUpdate")).toBeNull(); }); + + it("gives every per-player type a bell switch except a player's own account and safety notices", () => { + const staff = staffTypes(); + const perPlayer = types.filter( + (type) => + !staff.has(type) && + !ACCOUNT_AND_SAFETY.includes(type) && + !NEVER_IN_THE_BELL.includes(type), + ); + + expect(IN_APP_KEYS.map((entry) => entry.key).sort()).toEqual( + perPlayer.sort(), + ); + }); + + it("refuses a bell preference for a locked or push-only type", () => { + for (const type of [...ACCOUNT_AND_SAFETY, ...NEVER_IN_THE_BELL]) { + expect(isKnownKey("in_app", type)).toBe(false); + } + + expect(isKnownKey("in_app", "ScrimRequestReceived")).toBe(true); + expect(isKnownKey("in_app", "MatchStatusChange")).toBe(true); + }); +}); + +describe("bell control", () => { + const types = notificationTypesInTree(); + + it("classifies every notification type", () => { + const unclassified = types.filter((type) => !bellControlForType(type)); + + expect(unclassified).toEqual([]); + }); + + it("locks exactly a player's own account and safety notices", () => { + const staff = staffTypes(); + const locked = types.filter( + (type) => !staff.has(type) && bellControlForType(type) === "locked", + ); + + expect(locked.sort()).toEqual([...ACCOUNT_AND_SAFETY].sort()); + }); + + it("locks staff broadcasts, which have no recipient list to filter", () => { + const unlocked = [...staffTypes()].filter( + (type) => bellControlForType(type) !== "locked", + ); + + expect(unlocked).toEqual([]); + }); + + it("keeps the rings and chat out of the bell", () => { + expect(NEVER_IN_THE_BELL.map(bellControlForType)).toEqual([ + "push_only", + "push_only", + "push_only", + "push_only", + ]); + }); + + it("toggles exactly the in-app keys", () => { + const toggles = types.filter( + (type) => bellControlForType(type) === "toggle", + ); + + expect(toggles.sort()).toEqual( + IN_APP_KEYS.map((entry) => entry.key).sort(), + ); + }); +}); + +describe("category catalog", () => { + const types = notificationTypesInTree(); + + it("lists a category's types in order, with how the bell treats each", () => { + expect(typesForCategory("account")).toEqual([ + { type: "NameChangeApproved", bell: "locked", ignoresQuietHours: false }, + { type: "NameChangeDenied", bell: "locked", ignoresQuietHours: false }, + { type: "PlayerSanctioned", bell: "locked", ignoresQuietHours: false }, + { type: "PlayerWarning", bell: "locked", ignoresQuietHours: false }, + { type: "AwardGranted", bell: "toggle", ignoresQuietHours: false }, + ]); + }); + + it("says which types ring through quiet hours", () => { + expect(typesForCategory("match_found")).toEqual([ + { type: "MatchFound", bell: "push_only", ignoresQuietHours: true }, + ]); + expect(typesForCategory("admin_call")).toEqual([ + { type: "AdminCall", bell: "push_only", ignoresQuietHours: true }, + ]); + }); + + it("lists every type exactly once across the push categories", () => { + const listed = PUSH_KEYS.flatMap((entry) => + typesForCategory(entry.key).map(({ type }) => type), + ); + + expect([...listed].sort()).toEqual([...types].sort()); + }); + + it("lists nothing for a category that does not exist", () => { + expect(typesForCategory("nope")).toEqual([]); + }); }); diff --git a/src/notifications/preferences/notification-categories.ts b/src/notifications/preferences/notification-categories.ts index e8913e2b..955f4c50 100644 --- a/src/notifications/preferences/notification-categories.ts +++ b/src/notifications/preferences/notification-categories.ts @@ -1,4 +1,5 @@ import { e_notification_types_enum } from "generated/schema"; +import { deliveryPolicyForType } from "../push/notification-delivery"; export type NotificationChannel = "push" | "in_app"; @@ -111,36 +112,77 @@ export const PUSH_KEYS: PreferenceKey[] = [ ]; // The in-app bell is toggleable per individual type rather than per category, -// but only for a small hand-picked set -- everything else keeps firing with no -// user-facing control. +// for every type that reaches a player by steam id -- except the ones in +// LOCKED_IN_APP_TYPES and PUSH_ONLY_TYPES. // -// Every key here must be a type that carries a steam_id. Enforcement happens at -// insert time against a known recipient list, and a role-broadcast row has no -// such list to filter against. +// Enforcement happens at insert time against a known recipient list, so a type +// only belongs here if every per-player write of it goes through +// NotificationsService.notifyPlayers or notifyActivePlayers. A role-broadcast +// row has no such list to filter against, which is why staff types are absent. export const IN_APP_KEYS: PreferenceKey[] = [ - { key: "TeamInvite", defaultEnabled: true }, - { key: "TournamentTeamInvite", defaultEnabled: true }, - { key: "TournamentInvite", defaultEnabled: true }, - { key: "DraftInvite", defaultEnabled: true }, + { key: "MatchStatusChange", defaultEnabled: true }, { key: "MatchImported", defaultEnabled: true }, { key: "MatchStatsReady", defaultEnabled: true }, { key: "ClipReady", defaultEnabled: true }, - { key: "AwardGranted", defaultEnabled: true }, - { key: "TeammateBanned", defaultEnabled: true }, - { key: "NewsPublished", defaultEnabled: true }, + { key: "TournamentCreated", defaultEnabled: true }, { key: "TournamentReminder", defaultEnabled: true }, { key: "TournamentCheckInOpen", defaultEnabled: true }, { key: "TournamentCheckInClosing", defaultEnabled: true }, { key: "TournamentCheckInMissed", defaultEnabled: true }, + { key: "TournamentPartySignup", defaultEnabled: true }, { key: "EventReminder", defaultEnabled: true }, { key: "SeasonEnded", defaultEnabled: true }, - { key: "FormTeamSuggestion", defaultEnabled: true }, + { key: "ScrimRequestReceived", defaultEnabled: true }, + { key: "ScrimRequestCountered", defaultEnabled: true }, + { key: "ScrimRequestAccepted", defaultEnabled: true }, + { key: "ScrimRequestDeclined", defaultEnabled: true }, + { key: "ScrimRequestExpired", defaultEnabled: true }, + { key: "ScrimMatchScheduled", defaultEnabled: true }, + { key: "ScrimMatchCanceled", defaultEnabled: true }, + { key: "ScrimTimeChanged", defaultEnabled: true }, { key: "ScrimAlertMatch", defaultEnabled: true }, + { key: "LeagueProposalReceived", defaultEnabled: true }, + { key: "LeagueProposalAccepted", defaultEnabled: true }, + { key: "LeagueProposalDeclined", defaultEnabled: true }, { key: "LeagueMatchUnscheduled", defaultEnabled: true }, + { key: "LeagueRegistrationDecision", defaultEnabled: true }, + { key: "LeagueRosterUndersized", defaultEnabled: true }, + { key: "FormTeamSuggestion", defaultEnabled: true }, + { key: "TeamInvite", defaultEnabled: true }, + { key: "TournamentTeamInvite", defaultEnabled: true }, + { key: "TournamentInvite", defaultEnabled: true }, + { key: "DraftInvite", defaultEnabled: true }, { key: "UtilityPracticeInvite", defaultEnabled: true }, { key: "UtilityPracticeReady", defaultEnabled: true }, + { key: "AwardGranted", defaultEnabled: true }, + { key: "TeammateBanned", defaultEnabled: true }, + { key: "NewsPublished", defaultEnabled: true }, +]; + +// A player's own account and safety notices always reach the bell. +export const LOCKED_IN_APP_TYPES: e_notification_types_enum[] = [ + "NameChangeApproved", + "NameChangeDenied", + "PlayerSanctioned", + "PlayerWarning", +]; + +// Rings are written with in_app = false, and chat writes no rows at all. +export const PUSH_ONLY_TYPES: e_notification_types_enum[] = [ + "MatchFound", + "AdminCall", + "ChatMessage", + "MatchChatMessage", ]; +export type BellControl = "toggle" | "locked" | "push_only"; + +export type CategoryType = { + type: string; + bell: BellControl; + ignoresQuietHours: boolean; +}; + const PUSH_CATEGORY_BY_TYPE: Record = Object.fromEntries( Object.entries(PUSH_CATEGORIES).flatMap(([category, types]) => types.map((type) => [type, category]), @@ -161,13 +203,37 @@ export function inAppKeyForType(type: string): PreferenceKey | null { return IN_APP_KEY_BY_NAME.get(type) ?? null; } +export function bellControlForType(type: string): BellControl | null { + if (IN_APP_KEY_BY_NAME.has(type)) { + return "toggle"; + } + + if ((PUSH_ONLY_TYPES as string[]).includes(type)) { + return "push_only"; + } + + if ( + (LOCKED_IN_APP_TYPES as string[]).includes(type) || + pushCategoryForType(type)?.adminOnly + ) { + return "locked"; + } + + return null; +} + +export function typesForCategory(category: string): CategoryType[] { + return (PUSH_CATEGORIES[category] ?? []).map((type) => ({ + type, + bell: bellControlForType(type) ?? "locked", + ignoresQuietHours: Boolean(deliveryPolicyForType(type)?.ignoreQuietHours), + })); +} + export function keysForChannel(channel: NotificationChannel): PreferenceKey[] { return channel === "push" ? PUSH_KEYS : IN_APP_KEYS; } -export function isKnownKey( - channel: NotificationChannel, - key: string, -): boolean { +export function isKnownKey(channel: NotificationChannel, key: string): boolean { return keysForChannel(channel).some((entry) => entry.key === key); } diff --git a/src/notifications/preferences/notification-preferences.service.spec.ts b/src/notifications/preferences/notification-preferences.service.spec.ts new file mode 100644 index 00000000..a5e512f7 --- /dev/null +++ b/src/notifications/preferences/notification-preferences.service.spec.ts @@ -0,0 +1,67 @@ +import { NotificationPreferencesService } from "./notification-preferences.service"; + +describe("NotificationPreferencesService.list", () => { + const STEAM_ID = "76561198000000001"; + + const service = (stored: Array<{ key: string; enabled: boolean }>) => + new NotificationPreferencesService({ + query: jest.fn().mockResolvedValue(stored), + } as any); + + it("hands the page every type in each push category", async () => { + const rows = await service([{ key: "account", enabled: false }]).list( + STEAM_ID, + "push", + ); + + expect(rows.find((row) => row.key === "account")).toEqual({ + key: "account", + defaultEnabled: true, + enabled: false, + types: [ + { + type: "NameChangeApproved", + bell: "locked", + ignoresQuietHours: false, + }, + { type: "NameChangeDenied", bell: "locked", ignoresQuietHours: false }, + { type: "PlayerSanctioned", bell: "locked", ignoresQuietHours: false }, + { type: "PlayerWarning", bell: "locked", ignoresQuietHours: false }, + { type: "AwardGranted", bell: "toggle", ignoresQuietHours: false }, + ], + }); + }); + + it("keeps staff categories flagged so the page can hide them", async () => { + const rows = await service([]).list(STEAM_ID, "push"); + + expect(rows.find((row) => row.key === "staff_moderation")).toEqual( + expect.objectContaining({ + adminOnly: true, + types: [ + { type: "MatchSupport", bell: "locked", ignoresQuietHours: false }, + { type: "MatchAbandoned", bell: "locked", ignoresQuietHours: false }, + { + type: "NameChangeRequest", + bell: "locked", + ignoresQuietHours: false, + }, + ], + }), + ); + }); + + it("lists only the bell's own switches for in-app", async () => { + const rows = await service([ + { key: "ScrimRequestReceived", enabled: false }, + ]).list(STEAM_ID, "in_app"); + + expect(rows.find((row) => row.key === "ScrimRequestReceived")).toEqual({ + key: "ScrimRequestReceived", + defaultEnabled: true, + enabled: false, + }); + expect(rows.find((row) => row.key === "PlayerWarning")).toBeUndefined(); + expect(rows.find((row) => row.key === "MatchFound")).toBeUndefined(); + }); +}); diff --git a/src/notifications/preferences/notification-preferences.service.ts b/src/notifications/preferences/notification-preferences.service.ts index db9f2ed6..a4bb45c0 100644 --- a/src/notifications/preferences/notification-preferences.service.ts +++ b/src/notifications/preferences/notification-preferences.service.ts @@ -1,10 +1,12 @@ import { BadRequestException, Injectable } from "@nestjs/common"; import { PostgresService } from "../../postgres/postgres.service"; import { + CategoryType, NotificationChannel, PreferenceKey, keysForChannel, inAppKeyForType, + typesForCategory, } from "./notification-categories"; export type QuietHours = { @@ -15,6 +17,7 @@ export type QuietHours = { export type ResolvedPreference = PreferenceKey & { enabled: boolean; + types?: CategoryType[]; }; @Injectable() @@ -22,8 +25,8 @@ export class NotificationPreferencesService { constructor(private readonly postgres: PostgresService) {} // Returns the whole catalogue merged with the player's stored choices, so - // the frontend renders toggles without needing its own copy of the key list - // or of what each key defaults to. + // the frontend renders toggles without needing its own copy of the key list, + // of what each key defaults to, or of which types each push category covers. public async list( steamId: string, channel: NotificationChannel, @@ -42,6 +45,7 @@ export class NotificationPreferencesService { return keysForChannel(channel).map((entry) => ({ ...entry, enabled: stored.get(entry.key) ?? entry.defaultEnabled, + ...(channel === "push" ? { types: typesForCategory(entry.key) } : {}), })); }