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
8 changes: 7 additions & 1 deletion generated/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -13718,6 +13718,9 @@ enum e_notification_types_enum {
"""A player you recently played with received a sanction"""
PlayerSanctioned

"""A moderator issued you a warning"""
PlayerWarning

"""A team matching your scrim alert is available"""
ScrimAlertMatch

Expand Down Expand Up @@ -15192,6 +15195,9 @@ enum e_sanction_types_enum {

"""Player muted and gagged"""
silence

"""Informational note on the player's record; never enforced, never expires"""
warning
}

"""
Expand Down Expand Up @@ -53059,7 +53065,7 @@ type mutation_root {
uninstallGamePlugin(force: Boolean, slug: String!): SuccessOutput
unlinkDiscord: SuccessOutput
unlinkSteamMatchHistory: SuccessOutput
unsanctionServerPlayer(serverId: String, steam_id: String!, type: String!): SanctionResult!
unsanctionServerPlayer(sanction_id: uuid, serverId: String, steam_id: String!, type: String!): SanctionResult!

"""Owner-only patch for clip title / visibility / target_steam_id."""
updateClip(clip_id: uuid!, target_steam_id: String, title: String, visibility: String): SuccessOutput
Expand Down
10 changes: 6 additions & 4 deletions generated/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5662,7 +5662,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' | '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' | 'TournamentCheckInClosing' | 'TournamentCheckInMissed' | 'TournamentCheckInOpen' | 'TournamentCreated' | 'TournamentInvite' | 'TournamentPartySignup' | 'TournamentReminder' | 'TournamentTeamInvite' | 'UtilityDriftScanFinished' | 'UtilityPracticeInvite' | 'UtilityPracticeReady'


/** aggregate max on columns */
Expand Down Expand Up @@ -6209,7 +6209,7 @@ export interface e_sanction_types_aggregate_fields {
/** unique or primary key constraints on table "e_sanction_types" */
export type e_sanction_types_constraint = 'e_sanction_types_pkey'

export type e_sanction_types_enum = 'ban' | 'gag' | 'mute' | 'silence'
export type e_sanction_types_enum = 'ban' | 'gag' | 'mute' | 'silence' | 'warning'


/** aggregate max on columns */
Expand Down Expand Up @@ -75184,7 +75184,7 @@ export interface mutation_rootGenqlSelection{
uninstallGamePlugin?: (SuccessOutputGenqlSelection & { __args: {force?: (Scalars['Boolean'] | null), slug: Scalars['String']} })
unlinkDiscord?: SuccessOutputGenqlSelection
unlinkSteamMatchHistory?: SuccessOutputGenqlSelection
unsanctionServerPlayer?: (SanctionResultGenqlSelection & { __args: {serverId?: (Scalars['String'] | null), steam_id: Scalars['String'], type: Scalars['String']} })
unsanctionServerPlayer?: (SanctionResultGenqlSelection & { __args: {sanction_id?: (Scalars['uuid'] | null), serverId?: (Scalars['String'] | null), steam_id: Scalars['String'], type: Scalars['String']} })
/** Owner-only patch for clip title / visibility / target_steam_id. */
updateClip?: (SuccessOutputGenqlSelection & { __args: {clip_id: Scalars['uuid'], target_steam_id?: (Scalars['String'] | null), title?: (Scalars['String'] | null), visibility?: (Scalars['String'] | null)} })
updateCs?: (SuccessOutputGenqlSelection & { __args?: {game?: (Scalars['String'] | null), game_server_node_id?: (Scalars['uuid'] | null)} })
Expand Down Expand Up @@ -152217,6 +152217,7 @@ export const enumENotificationTypesEnum = {
NewsPublished: 'NewsPublished' as const,
PlayerReindex: 'PlayerReindex' as const,
PlayerSanctioned: 'PlayerSanctioned' as const,
PlayerWarning: 'PlayerWarning' as const,
ScrimAlertMatch: 'ScrimAlertMatch' as const,
ScrimMatchCanceled: 'ScrimMatchCanceled' as const,
ScrimMatchScheduled: 'ScrimMatchScheduled' as const,
Expand Down Expand Up @@ -152384,7 +152385,8 @@ export const enumESanctionTypesEnum = {
ban: 'ban' as const,
gag: 'gag' as const,
mute: 'mute' as const,
silence: 'silence' as const
silence: 'silence' as const,
warning: 'warning' as const
}

export const enumESanctionTypesSelectColumn = {
Expand Down
3 changes: 3 additions & 0 deletions generated/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198564,6 +198564,9 @@ export default {
"unsanctionServerPlayer": [
71,
{
"sanction_id": [
6739
],
"serverId": [
85
],
Expand Down
1 change: 1 addition & 0 deletions hasura/enums/notification-types.sql
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ INSERT INTO e_notification_types ("value", "description") VALUES
('UtilityPracticeReady', 'Your utility practice server is ready'),
('UtilityDriftScanFinished', 'A utility drift scan finished'),
('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')
ON CONFLICT("value") DO UPDATE
Expand Down
3 changes: 2 additions & 1 deletion hasura/enums/sanction-types.sql
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ insert into e_sanction_types ("value", "description") values
('ban', 'Player is not able to participate in any activity'),
('mute', 'Player cannot use voice chat in game'),
('gag', 'Player cannot use text chat in game'),
('silence', 'Player muted and gagged')
('silence', 'Player muted and gagged'),
('warning', 'Informational note on the player''s record; never enforced, never expires')
on conflict(value) do update set "description" = EXCLUDED."description"
1 change: 1 addition & 0 deletions hasura/metadata/actions.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -810,6 +810,7 @@ type Mutation {
serverId: String
steam_id: String!
type: String!
sanction_id: uuid
): SanctionResult!
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,23 @@ object_relationships:
foreign_key_constraint_on: sanctioned_by_steam_id
select_permissions:
- role: guest
permission:
columns:
- player_steam_id
- sanctioned_by_steam_id
- reason
- type
- created_at
- remove_sanction_date
- id
filter:
_and:
- deleted_at:
_is_null: true
- type:
_neq: warning
comment: ""
- role: moderator
permission:
columns:
- player_steam_id
Expand All @@ -26,14 +43,37 @@ select_permissions:
deleted_at:
_is_null: true
comment: ""
- role: user
permission:
columns:
- player_steam_id
- sanctioned_by_steam_id
- reason
- type
- created_at
- remove_sanction_date
- id
filter:
_and:
- deleted_at:
_is_null: true
- _or:
- type:
_neq: warning
- player_steam_id:
_eq: X-Hasura-User-Id
comment: ""
update_permissions:
- role: moderator
permission:
columns:
- remove_sanction_date
filter:
deleted_at:
_is_null: true
_and:
- deleted_at:
_is_null: true
- type:
_neq: warning
check: null
comment: ""
event_triggers:
Expand Down
15 changes: 15 additions & 0 deletions hasura/triggers/player_sanctions.sql
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,18 @@ $$;

DROP TRIGGER IF EXISTS tau_player_sanctions ON public.player_sanctions;
CREATE TRIGGER tau_player_sanctions AFTER UPDATE ON public.player_sanctions FOR EACH ROW EXECUTE FUNCTION public.tau_player_sanctions();

CREATE OR REPLACE FUNCTION public.tbiu_player_sanctions() RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.type = 'warning' THEN
NEW.remove_sanction_date := NULL;
END IF;

RETURN NEW;
END;
$$;

DROP TRIGGER IF EXISTS tbiu_player_sanctions ON public.player_sanctions;
CREATE TRIGGER tbiu_player_sanctions BEFORE INSERT OR UPDATE ON public.player_sanctions FOR EACH ROW EXECUTE FUNCTION public.tbiu_player_sanctions();
58 changes: 58 additions & 0 deletions src/hasura/metadata-permissions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,64 @@ describe("hasura table metadata", () => {
).toEqual(["administrator"]);
});

// A warning is a private note between the player and staff; every other
// sanction stays on the public record.
describe("player_sanctions warnings", () => {
const sanctions = () =>
tables.find(({ file }) => file === "public_player_sanctions.yaml")
?.metadata;

const selectFilter = (role: string) =>
blocksByRole(sanctions(), "select_permissions").get(role)?.filter;

it("hides warnings from guests", () => {
expect(selectFilter("guest")).toEqual({
_and: [
{ deleted_at: { _is_null: true } },
{ type: { _neq: "warning" } },
],
});
});

it("shows a user only their own warnings", () => {
expect(selectFilter("user")).toEqual({
_and: [
{ deleted_at: { _is_null: true } },
{
_or: [
{ type: { _neq: "warning" } },
{ player_steam_id: { _eq: "X-Hasura-User-Id" } },
],
},
],
});
});

it("shows moderators every warning", () => {
expect(selectFilter("moderator")).toEqual({
deleted_at: { _is_null: true },
});
});

it("only defines select for the roles that change what is visible", () => {
expect(
[...blocksByRole(sanctions(), "select_permissions").keys()].sort(),
).toEqual(["guest", "moderator", "user"]);
});

it("never lets a moderator give a warning an end date", () => {
expect(
blocksByRole(sanctions(), "update_permissions").get("moderator")
?.filter,
).toEqual({
_and: [
{ deleted_at: { _is_null: true } },
{ type: { _neq: "warning" } },
],
});
});
});

it("never lists the same name as both a column and a computed field", () => {
const problems: Array<string> = [];

Expand Down
1 change: 1 addition & 0 deletions src/notifications/jobs/SendSanctionNotifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export class SendSanctionNotifications extends WorkerHost {
}>,
): 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);
}
Expand Down
60 changes: 59 additions & 1 deletion 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",
"PlayerWarning",
"MatchImported",
"MatchFound",
"AdminCall",
Expand Down Expand Up @@ -368,7 +369,7 @@ describe("NotificationsService", () => {
});

describe("notifyMatchPlayersOfSanction", () => {
it.each(["mute", "gag", "silence"])(
it.each(["mute", "gag", "silence", "warning"])(
"keeps a %s between the player and staff",
async (type) => {
await service.notifyMatchPlayersOfSanction(sanction(type));
Expand All @@ -390,4 +391,61 @@ describe("NotificationsService", () => {
);
});
});

describe("warnings", () => {
const signedIn = () =>
hasura.query.mockResolvedValue({
players_by_pk: { last_sign_in_at: "2026-09-01T00:00:00.000Z" },
});

it("tells only the warned player, with the reason escaped", async () => {
signedIn();

await service.notifyWarnedPlayer({
...sanction("warning"),
reason: `<b>spam</b> & "toxic"`,
});

expect(notifyPlayers).toHaveBeenCalledTimes(1);
const [type, notification] = notifyPlayers.mock.calls[0];
expect(type).toBe("PlayerWarning");
expect(notification).toEqual({
title: "You received a warning",
message: "&lt;b&gt;spam&lt;/b&gt; &amp; &quot;toxic&quot;",
role: "user",
entity_id: "76561198000000001",
steamIds: ["76561198000000001"],
});
});

it("skips a player who has never signed in", async () => {
hasura.query.mockResolvedValue({
players_by_pk: { last_sign_in_at: null },
});

await service.notifyWarnedPlayer(sanction("warning"));

expect(notifyPlayers).not.toHaveBeenCalled();
});

it.each(["ban", "mute", "gag", "silence"])("ignores a %s", async (type) => {
signedIn();

await service.notifyWarnedPlayer(sanction(type));

expect(hasura.query).not.toHaveBeenCalled();
expect(notifyPlayers).not.toHaveBeenCalled();
});

it("never reaches admins or the banned-player notice", async () => {
signedIn();

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

expect(hasura.query).not.toHaveBeenCalled();
expect(hasura.mutation).not.toHaveBeenCalled();
expect(postgres.query).not.toHaveBeenCalled();
});
});
});
31 changes: 31 additions & 0 deletions src/notifications/notifications.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,37 @@ export class NotificationsService {
this.logger.log(`notified banned player ${sanction.steamId}`);
}

async notifyWarnedPlayer(sanction: {
sanctionId: string;
steamId: string;
type: string;
reason?: string | null;
}): Promise<void> {
if (sanction.type !== "warning") {
return;
}

const { players_by_pk } = await this.hasura.query({
players_by_pk: {
__args: { steam_id: sanction.steamId },
last_sign_in_at: true,
},
});
if (!players_by_pk?.last_sign_in_at) {
return;
}

await this.notifyPlayers("PlayerWarning", {
title: "You received a warning",
message: NotificationsService.escapeHtml(sanction.reason),
role: "user",
entity_id: sanction.steamId,
steamIds: [sanction.steamId],
});

this.logger.log(`notified warned player ${sanction.steamId}`);
}

async send(
type: e_notification_types_enum,
notification: {
Expand Down
8 changes: 7 additions & 1 deletion src/notifications/preferences/notification-categories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,13 @@ export const PUSH_CATEGORIES: Record<string, e_notification_types_enum[]> = {
"DraftInvite",
],
utility: ["UtilityPracticeInvite", "UtilityPracticeReady"],
account: ["NameChangeApproved", "NameChangeDenied", "PlayerSanctioned", "AwardGranted"],
account: [
"NameChangeApproved",
"NameChangeDenied",
"PlayerSanctioned",
"PlayerWarning",
"AwardGranted",
],
news: ["NewsPublished"],
staff_moderation: ["MatchSupport", "MatchAbandoned", "NameChangeRequest"],
staff_infrastructure: [
Expand Down
Loading
Loading