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) } : {}), })); }