Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion generated/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -14756,7 +14756,7 @@ enum e_notification_types_enum {
"""Player search reindex finished"""
PlayerReindex

"""A player you recently played with received a sanction"""
"""You were banned, or (for staff) a player was banned"""
PlayerSanctioned

"""A moderator issued you a warning"""
Expand Down Expand Up @@ -14798,6 +14798,9 @@ enum e_notification_types_enum {
"""You were invited to a team"""
TeamInvite

"""A player you played with in the last 6 months was banned"""
TeammateBanned

"""Check-in for your tournament closes soon"""
TournamentCheckInClosing

Expand Down
3 changes: 2 additions & 1 deletion generated/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6227,7 +6227,7 @@ export interface e_notification_types_aggregate_fields {
/** unique or primary key constraints on table "e_notification_types" */
export type e_notification_types_constraint = 'e_notification_types_pkey'

export type e_notification_types_enum = 'AdminCall' | 'AwardGranted' | 'ChatMessage' | 'ClipReady' | 'DedicatedServerRconStatus' | 'DedicatedServerStatus' | 'DraftInvite' | 'EloRecompute' | 'EventReminder' | 'FormTeamSuggestion' | 'GameNodeStatus' | 'GameUpdate' | 'LeagueMatchUnscheduled' | 'LeagueProposalAccepted' | 'LeagueProposalDeclined' | 'LeagueProposalReceived' | 'LeagueRegistrationDecision' | 'LeagueRosterUndersized' | 'MatchAbandoned' | 'MatchChatMessage' | 'MatchFound' | 'MatchImported' | 'MatchStatsReady' | 'MatchStatusChange' | 'MatchSupport' | 'NadeDriftScanFinished' | 'NadePracticeInvite' | 'NadePracticeReady' | 'NameChangeApproved' | 'NameChangeDenied' | 'NameChangeRequest' | 'NewsPublished' | 'PlayerReindex' | 'PlayerSanctioned' | 'PlayerWarning' | 'ScrimAlertMatch' | 'ScrimMatchCanceled' | 'ScrimMatchScheduled' | 'ScrimRequestAccepted' | 'ScrimRequestCountered' | 'ScrimRequestDeclined' | 'ScrimRequestExpired' | 'ScrimRequestReceived' | 'ScrimTimeChanged' | 'SeasonEnded' | 'StorageScan' | 'TeamInvite' | 'TournamentCheckInClosing' | 'TournamentCheckInMissed' | 'TournamentCheckInOpen' | 'TournamentCreated' | 'TournamentInvite' | 'TournamentPartySignup' | 'TournamentReminder' | 'TournamentTeamInvite' | 'UtilityDriftScanFinished' | 'UtilityPracticeInvite' | 'UtilityPracticeReady'
export type e_notification_types_enum = 'AdminCall' | 'AwardGranted' | 'ChatMessage' | 'ClipReady' | 'DedicatedServerRconStatus' | 'DedicatedServerStatus' | 'DraftInvite' | 'EloRecompute' | 'EventReminder' | 'FormTeamSuggestion' | 'GameNodeStatus' | 'GameUpdate' | 'LeagueMatchUnscheduled' | 'LeagueProposalAccepted' | 'LeagueProposalDeclined' | 'LeagueProposalReceived' | 'LeagueRegistrationDecision' | 'LeagueRosterUndersized' | 'MatchAbandoned' | 'MatchChatMessage' | 'MatchFound' | 'MatchImported' | 'MatchStatsReady' | 'MatchStatusChange' | 'MatchSupport' | 'NadeDriftScanFinished' | 'NadePracticeInvite' | 'NadePracticeReady' | 'NameChangeApproved' | 'NameChangeDenied' | 'NameChangeRequest' | 'NewsPublished' | 'PlayerReindex' | 'PlayerSanctioned' | 'PlayerWarning' | 'ScrimAlertMatch' | 'ScrimMatchCanceled' | 'ScrimMatchScheduled' | 'ScrimRequestAccepted' | 'ScrimRequestCountered' | 'ScrimRequestDeclined' | 'ScrimRequestExpired' | 'ScrimRequestReceived' | 'ScrimTimeChanged' | 'SeasonEnded' | 'StorageScan' | 'TeamInvite' | 'TeammateBanned' | 'TournamentCheckInClosing' | 'TournamentCheckInMissed' | 'TournamentCheckInOpen' | 'TournamentCreated' | 'TournamentInvite' | 'TournamentPartySignup' | 'TournamentReminder' | 'TournamentTeamInvite' | 'UtilityDriftScanFinished' | 'UtilityPracticeInvite' | 'UtilityPracticeReady'


/** aggregate max on columns */
Expand Down Expand Up @@ -162460,6 +162460,7 @@ export const enumENotificationTypesEnum = {
SeasonEnded: 'SeasonEnded' as const,
StorageScan: 'StorageScan' as const,
TeamInvite: 'TeamInvite' as const,
TeammateBanned: 'TeammateBanned' as const,
TournamentCheckInClosing: 'TournamentCheckInClosing' as const,
TournamentCheckInMissed: 'TournamentCheckInMissed' as const,
TournamentCheckInOpen: 'TournamentCheckInOpen' as const,
Expand Down
5 changes: 3 additions & 2 deletions hasura/enums/notification-types.sql
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ INSERT INTO e_notification_types ("value", "description") VALUES
('DedicatedServerRconStatus', 'DedicatedServerRconStatus'),
('MatchStatusChange', 'Match Status Change Notification'),
('StorageScan', 'Storage Scan'),
('PlayerSanctioned', 'A player you recently played with received a sanction'),
('PlayerSanctioned', 'You were banned, or (for staff) a player was banned'),
('ScrimRequestReceived', 'A team requested to scrim yours'),
('ScrimRequestCountered', 'A team proposed a different scrim time'),
('ScrimRequestAccepted', 'Your scrim request was accepted'),
Expand Down Expand Up @@ -52,6 +52,7 @@ INSERT INTO e_notification_types ("value", "description") VALUES
('TournamentPartySignup', 'Your lobby was signed up for a tournament as a free agent party'),
('PlayerWarning', 'A moderator issued you a warning'),
('MatchFound', 'A match was found and is waiting for you to ready up'),
('AdminCall', 'An admin is calling you')
('AdminCall', 'An admin is calling you'),
('TeammateBanned', 'A player you played with in the last 6 months was banned')
ON CONFLICT("value") DO UPDATE
SET "description" = EXCLUDED."description";
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
DELETE FROM public.notification_preferences
WHERE channel = 'push'
AND key = 'teammate_bans';
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-- Banned-teammate pushes used to be part of account, so an account opt-out carries over.
INSERT INTO public.notification_preferences (steam_id, channel, key, enabled)
SELECT steam_id, channel, 'teammate_bans', false
FROM public.notification_preferences
WHERE channel = 'push'
AND key = 'account'
AND enabled = false
ON CONFLICT (steam_id, channel, key) DO NOTHING;
67 changes: 67 additions & 0 deletions src/notifications/jobs/SendSanctionNotifications.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { SendSanctionNotifications } from "./SendSanctionNotifications";

describe("SendSanctionNotifications", () => {
const ban = {
sanctionId: "sanction-1",
steamId: "76561198000000001",
type: "ban",
reason: "cheating",
};

let order: string[];
let notifications: Record<string, jest.Mock>;
let logger: { error: jest.Mock };
let job: SendSanctionNotifications;

const step = (name: string) =>
jest.fn(async () => {
order.push(name);
});

beforeEach(() => {
order = [];
notifications = {
notifyBannedPlayer: step("banned player"),
notifyWarnedPlayer: step("warned player"),
notifyAdminsOfBan: step("admins"),
notifyMatchPlayersOfSanction: step("co-players"),
};
logger = { error: jest.fn() };
job = new SendSanctionNotifications(notifications as any, logger as any);
});

it("tells the banned player and the admins before the co-player fan-out", async () => {
await job.process({ data: ban } as any);

expect(order).toEqual([
"banned player",
"warned player",
"admins",
"co-players",
]);
});

it("still alerts the admins when the co-player fan-out throws", async () => {
notifications.notifyMatchPlayersOfSanction.mockRejectedValue(
new Error("value 'TeammateBanned' for enum not found"),
);

await job.process({ data: ban } as any);

expect(notifications.notifyAdminsOfBan).toHaveBeenCalledWith(ban);
expect(logger.error).toHaveBeenCalledTimes(1);
});

it("still reaches the co-players when an earlier notice throws", async () => {
notifications.notifyBannedPlayer.mockRejectedValue(new Error("down"));
notifications.notifyAdminsOfBan.mockRejectedValue(new Error("down"));

await job.process({ data: ban } as any);

expect(notifications.notifyWarnedPlayer).toHaveBeenCalledWith(ban);
expect(notifications.notifyMatchPlayersOfSanction).toHaveBeenCalledWith(
ban,
);
expect(logger.error).toHaveBeenCalledTimes(2);
});
});
51 changes: 38 additions & 13 deletions src/notifications/jobs/SendSanctionNotifications.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,51 @@
import { WorkerHost } from "@nestjs/bullmq";
import { Logger } from "@nestjs/common";
import { Job } from "bullmq";
import { UseQueue } from "../../utilities/QueueProcessors";
import { NotificationsQueues } from "../enums/NotificationsQueues";
import { NotificationsService } from "../notifications.service";

type SanctionNotification = {
sanctionId: string;
steamId: string;
type: string;
reason?: string | null;
};

@UseQueue("Notifications", NotificationsQueues.SanctionNotifications)
export class SendSanctionNotifications extends WorkerHost {
constructor(private readonly notifications: NotificationsService) {
constructor(
private readonly notifications: NotificationsService,
private readonly logger: Logger,
) {
super();
}

async process(
job: Job<{
sanctionId: string;
steamId: string;
type: string;
reason?: string | null;
}>,
): Promise<void> {
await this.notifications.notifyBannedPlayer(job.data);
await this.notifications.notifyWarnedPlayer(job.data);
await this.notifications.notifyMatchPlayersOfSanction(job.data);
await this.notifications.notifyAdminsOfBan(job.data);
// The job runs once and its failed id is kept for an hour, so one notice
// failing must not cost the others; the co-player fan-out is the largest and
// goes last.
async process(job: Job<SanctionNotification>): Promise<void> {
const steps: Array<
[string, (data: SanctionNotification) => Promise<void>]
> = [
["banned player", (data) => this.notifications.notifyBannedPlayer(data)],
["warned player", (data) => this.notifications.notifyWarnedPlayer(data)],
["admins", (data) => this.notifications.notifyAdminsOfBan(data)],
[
"co-players",
(data) => this.notifications.notifyMatchPlayersOfSanction(data),
],
];

for (const [name, notify] of steps) {
try {
await notify(job.data);
} catch (error) {
this.logger.error(
`failed to notify ${name} of sanction ${job.data.sanctionId} on ${job.data.steamId}`,
error,
);
}
}
}
}
37 changes: 35 additions & 2 deletions src/notifications/notifications.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ describe("discord routing", () => {
"ChatMessage",
"MatchChatMessage",
"PlayerSanctioned",
"TeammateBanned",
"PlayerWarning",
"MatchImported",
"MatchFound",
Expand Down Expand Up @@ -379,17 +380,49 @@ describe("NotificationsService", () => {
},
);

it("tells recent team-mates about a ban", async () => {
it("tells recent team-mates about a ban as a banned-teammate notice", async () => {
await service.notifyMatchPlayersOfSanction(sanction("ban"));

expect(notifyPlayers).toHaveBeenCalledTimes(1);
const [type, notification] = notifyPlayers.mock.calls[0];
expect(type).toBe("PlayerSanctioned");
expect(type).toBe("TeammateBanned");
expect(notification.title).toBe("Player Banned");
expect(notification.entity_id).toBe("76561198000000001");
expect(notification.steamIds).toEqual(["76561198000000002"]);
expect(notification.message).toContain(
`<a href="${webDomain}/players/76561198000000001">keith</a>, was banned. (cheating)`,
);
});

it("tells them about an automatic Steam ban too", async () => {
await service.notifyMatchPlayersOfSanction({
...sanction("ban"),
reason: "VAC ban on record (1 ban)",
});

expect(notifyPlayers).toHaveBeenCalledTimes(1);
expect(notifyPlayers.mock.calls[0][0]).toBe("TeammateBanned");
});
});

describe("the banned player's own notice and the admin alert", () => {
const insertedTypes = () =>
hasura.mutation.mock.calls.map(
([mutation]) => mutation.insert_notifications.__args.objects[0].type,
);

it("stay on PlayerSanctioned", async () => {
hasura.query.mockResolvedValue({
players_by_pk: { last_sign_in_at: "2026-09-01T00:00:00.000Z" },
});
postgres.query.mockResolvedValue([{ exists: true }]);

await service.notifyBannedPlayer(sanction("ban"));
await service.notifyAdminsOfBan(sanction("ban"));

expect(insertedTypes()).toEqual(["PlayerSanctioned", "PlayerSanctioned"]);
expect(notifyPlayers).not.toHaveBeenCalled();
});
});

describe("warnings", () => {
Expand Down
4 changes: 2 additions & 2 deletions src/notifications/notifications.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,8 @@ export class NotificationsService {
// months of team-mates is routinely hundreds of rows and the event trigger
// fires per row, so every one of them resolved recipients and sent on its
// own; notifyPlayers claims the burst so a single job covers it.
const notified = await this.notifyPlayers("PlayerSanctioned", {
title: "Player Sanctioned",
const notified = await this.notifyPlayers("TeammateBanned", {
title: "Player Banned",
message,
role: "user",
entity_id: sanction.steamId,
Expand Down
21 changes: 21 additions & 0 deletions src/notifications/preferences/notification-categories.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,27 @@ describe("notification categories", () => {
expect(inAppKeyForType("MatchChatMessage")).toBeNull();
});

it("gives banned teammates their own push category, on by default, after account", () => {
expect(pushCategoryForType("TeammateBanned")).toEqual({
key: "teammate_bans",
defaultEnabled: true,
});

const keys = PUSH_KEYS.map((entry) => entry.key);
expect(keys.indexOf("teammate_bans")).toBe(keys.indexOf("account") + 1);
});

it("keeps a player's own sanction under account", () => {
expect(pushCategoryForType("PlayerSanctioned")?.key).toBe("account");
});

it("lets the bell mute banned teammates, on by default", () => {
expect(inAppKeyForType("TeammateBanned")).toEqual({
key: "TeammateBanned",
defaultEnabled: true,
});
});

it("resolves in-app keys back to their own type", () => {
for (const entry of IN_APP_KEYS) {
expect(inAppKeyForType(entry.key)).toEqual(entry);
Expand Down
3 changes: 3 additions & 0 deletions src/notifications/preferences/notification-categories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export const PUSH_CATEGORIES: Record<string, e_notification_types_enum[]> = {
"PlayerWarning",
"AwardGranted",
],
teammate_bans: ["TeammateBanned"],
news: ["NewsPublished"],
staff_moderation: ["MatchSupport", "MatchAbandoned", "NameChangeRequest"],
staff_infrastructure: [
Expand Down Expand Up @@ -102,6 +103,7 @@ export const PUSH_KEYS: PreferenceKey[] = [
{ key: "invites", defaultEnabled: true },
{ key: "utility", defaultEnabled: true },
{ key: "account", defaultEnabled: true },
{ key: "teammate_bans", defaultEnabled: true },
{ key: "news", defaultEnabled: true },
{ key: "staff_moderation", defaultEnabled: true, adminOnly: true },
// Infrastructure chatter is constant and rarely actionable on a phone.
Expand All @@ -124,6 +126,7 @@ export const IN_APP_KEYS: PreferenceKey[] = [
{ key: "MatchStatsReady", defaultEnabled: true },
{ key: "ClipReady", defaultEnabled: true },
{ key: "AwardGranted", defaultEnabled: true },
{ key: "TeammateBanned", defaultEnabled: true },
{ key: "NewsPublished", defaultEnabled: true },
{ key: "TournamentReminder", defaultEnabled: true },
{ key: "TournamentCheckInOpen", defaultEnabled: true },
Expand Down
10 changes: 10 additions & 0 deletions src/notifications/push/notification-delivery.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,16 @@ describe("notification delivery policies", () => {
});
});

it("delivers a banned-teammate notice the way the sanction notice it split from was", () => {
expect(deliveryPolicyForType("TeammateBanned")).toEqual({
bundleSeconds: 0,
requireUnseen: true,
});
expect(deliveryPolicyForType("TeammateBanned")).toEqual(
deliveryPolicyForType("PlayerSanctioned"),
);
});

it.each(["MatchFound", "AdminCall"])(
"rings %s now, even in quiet hours, and lets it expire with its window",
(type) => {
Expand Down
1 change: 1 addition & 0 deletions src/notifications/push/notification-delivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ const DELIVERY_POLICIES: Record<string, e_notification_types_enum[]> = {
"NameChangeApproved",
"NameChangeDenied",
"PlayerSanctioned",
"TeammateBanned",
"PlayerWarning",
"TournamentReminder",
"TournamentCheckInOpen",
Expand Down
2 changes: 1 addition & 1 deletion src/notifications/push/push-notifications.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1329,7 +1329,7 @@ describe("PushNotificationsService", () => {
expect(PushNotificationsService.isBatched("TournamentCreated")).toBe(true);
// A notifyPlayers fan-out rather than a notifyActivePlayers one, and the
// largest of them: every co-player from six months of matches.
expect(PushNotificationsService.isBatched("PlayerSanctioned")).toBe(true);
expect(PushNotificationsService.isBatched("TeammateBanned")).toBe(true);
expect(PushNotificationsService.isBatched("MatchStatusChange")).toBe(false);
});
});
Expand Down
6 changes: 3 additions & 3 deletions src/notifications/push/push-notifications.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,9 +204,9 @@ const BATCHED_TYPES = new Set<string>([
// fails -- and the point of the claim failing is to degrade to one batched
// job, not to one send per player.
"SeasonEnded",
// Six months of the sanctioned player's team-mates, every row carrying their
// Six months of the banned player's team-mates, every row carrying their
// steam id as the entity, which is what the jobId collapses on.
"PlayerSanctioned",
"TeammateBanned",
]);

const SEND_CHUNK_SIZE = 25;
Expand Down Expand Up @@ -491,7 +491,7 @@ export class PushNotificationsService {

// The id a burst collapses onto. Bucketed by time as well as by entity: the
// entity alone is stable for a type that can happen to the same entity twice.
// PlayerSanctioned keys on the sanctioned player's steam id, so muting a
// The co-player sanction notice keys on the player's steam id, so muting a
// player at 10:00 and banning them at 10:20 produced the same jobId, and
// BullMQ rejected the second burst as a duplicate of the completed job it
// retains for an hour -- nobody was told about the ban. Worst case a burst
Expand Down
Loading
Loading