diff --git a/generated/schema.graphql b/generated/schema.graphql index 3d2a4ac0..0d74487c 100644 --- a/generated/schema.graphql +++ b/generated/schema.graphql @@ -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 @@ -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 } """ @@ -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 diff --git a/generated/schema.ts b/generated/schema.ts index 11849c57..8f10a42b 100644 --- a/generated/schema.ts +++ b/generated/schema.ts @@ -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 */ @@ -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 */ @@ -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)} }) @@ -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, @@ -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 = { diff --git a/generated/types.ts b/generated/types.ts index 4a5e9d06..5fb284a6 100644 --- a/generated/types.ts +++ b/generated/types.ts @@ -198564,6 +198564,9 @@ export default { "unsanctionServerPlayer": [ 71, { + "sanction_id": [ + 6739 + ], "serverId": [ 85 ], diff --git a/hasura/enums/notification-types.sql b/hasura/enums/notification-types.sql index 97d44c45..4336afd3 100644 --- a/hasura/enums/notification-types.sql +++ b/hasura/enums/notification-types.sql @@ -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 diff --git a/hasura/enums/sanction-types.sql b/hasura/enums/sanction-types.sql index a6fcb5cd..bf6877ce 100644 --- a/hasura/enums/sanction-types.sql +++ b/hasura/enums/sanction-types.sql @@ -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" diff --git a/hasura/metadata/actions.graphql b/hasura/metadata/actions.graphql index 708f4074..5295b748 100644 --- a/hasura/metadata/actions.graphql +++ b/hasura/metadata/actions.graphql @@ -810,6 +810,7 @@ type Mutation { serverId: String steam_id: String! type: String! + sanction_id: uuid ): SanctionResult! } diff --git a/hasura/metadata/databases/default/tables/public_player_sanctions.yaml b/hasura/metadata/databases/default/tables/public_player_sanctions.yaml index bf81d2c1..9ecb52ac 100644 --- a/hasura/metadata/databases/default/tables/public_player_sanctions.yaml +++ b/hasura/metadata/databases/default/tables/public_player_sanctions.yaml @@ -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 @@ -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: diff --git a/hasura/triggers/player_sanctions.sql b/hasura/triggers/player_sanctions.sql index a7de120c..2f75f78b 100644 --- a/hasura/triggers/player_sanctions.sql +++ b/hasura/triggers/player_sanctions.sql @@ -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(); diff --git a/src/hasura/metadata-permissions.spec.ts b/src/hasura/metadata-permissions.spec.ts index 0387613a..6b6b01c4 100644 --- a/src/hasura/metadata-permissions.spec.ts +++ b/src/hasura/metadata-permissions.spec.ts @@ -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 = []; diff --git a/src/notifications/jobs/SendSanctionNotifications.ts b/src/notifications/jobs/SendSanctionNotifications.ts index acb7aa81..44946af2 100644 --- a/src/notifications/jobs/SendSanctionNotifications.ts +++ b/src/notifications/jobs/SendSanctionNotifications.ts @@ -19,6 +19,7 @@ export class SendSanctionNotifications extends WorkerHost { }>, ): Promise { await this.notifications.notifyBannedPlayer(job.data); + await this.notifications.notifyWarnedPlayer(job.data); await this.notifications.notifyMatchPlayersOfSanction(job.data); await this.notifications.notifyAdminsOfBan(job.data); } diff --git a/src/notifications/notifications.service.spec.ts b/src/notifications/notifications.service.spec.ts index 5ad03984..2ab240cd 100644 --- a/src/notifications/notifications.service.spec.ts +++ b/src/notifications/notifications.service.spec.ts @@ -50,6 +50,7 @@ describe("discord routing", () => { "ChatMessage", "MatchChatMessage", "PlayerSanctioned", + "PlayerWarning", "MatchImported", "MatchFound", "AdminCall", @@ -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)); @@ -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: `spam & "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: "<b>spam</b> & "toxic"", + 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(); + }); + }); }); diff --git a/src/notifications/notifications.service.ts b/src/notifications/notifications.service.ts index 53a911a5..f42a03c4 100644 --- a/src/notifications/notifications.service.ts +++ b/src/notifications/notifications.service.ts @@ -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 { + 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: { diff --git a/src/notifications/preferences/notification-categories.ts b/src/notifications/preferences/notification-categories.ts index ed7fdf08..0cba34cf 100644 --- a/src/notifications/preferences/notification-categories.ts +++ b/src/notifications/preferences/notification-categories.ts @@ -63,7 +63,13 @@ export const PUSH_CATEGORIES: Record = { "DraftInvite", ], utility: ["UtilityPracticeInvite", "UtilityPracticeReady"], - account: ["NameChangeApproved", "NameChangeDenied", "PlayerSanctioned", "AwardGranted"], + account: [ + "NameChangeApproved", + "NameChangeDenied", + "PlayerSanctioned", + "PlayerWarning", + "AwardGranted", + ], news: ["NewsPublished"], staff_moderation: ["MatchSupport", "MatchAbandoned", "NameChangeRequest"], staff_infrastructure: [ diff --git a/src/notifications/push/notification-delivery.ts b/src/notifications/push/notification-delivery.ts index 8bb54745..33a523ac 100644 --- a/src/notifications/push/notification-delivery.ts +++ b/src/notifications/push/notification-delivery.ts @@ -63,6 +63,7 @@ const DELIVERY_POLICIES: Record = { "NameChangeApproved", "NameChangeDenied", "PlayerSanctioned", + "PlayerWarning", "TournamentReminder", "TournamentCheckInOpen", "TournamentCheckInClosing", diff --git a/src/notifications/push/push-notifications.service.spec.ts b/src/notifications/push/push-notifications.service.spec.ts index 4827b2c8..7c47a278 100644 --- a/src/notifications/push/push-notifications.service.spec.ts +++ b/src/notifications/push/push-notifications.service.spec.ts @@ -1270,6 +1270,19 @@ describe("notificationUrl", () => { ).toBe("/matches/m-1"); }); + it("lands a warning on the warned player's profile, whatever the reason says", () => { + expect( + notificationUrl( + { + type: "PlayerWarning", + message: "<a href="/elsewhere">read</a>", + entity_id: "76561198000000001", + }, + webDomain, + ), + ).toBe("/players/76561198000000001"); + }); + it("strips the reminder window off a tournament entity id", () => { expect( notificationUrl( diff --git a/src/notifications/utilities/notificationUrl.ts b/src/notifications/utilities/notificationUrl.ts index e36095d4..7d8e5872 100644 --- a/src/notifications/utilities/notificationUrl.ts +++ b/src/notifications/utilities/notificationUrl.ts @@ -46,6 +46,7 @@ const PATH_BY_TYPE: Record string> = { NameChangeApproved: () => `/settings`, NameChangeDenied: () => `/settings`, NameChangeRequest: (id) => `/players/${id}`, + PlayerWarning: (id) => `/players/${id}`, // A league notification is keyed by a bracket or a team's season entry, // neither of which is addressable on its own -- the season id that would // build /league/seasons/:id is not on the row. diff --git a/src/sanctions/sanction-types.ts b/src/sanctions/sanction-types.ts new file mode 100644 index 00000000..072dc9ca --- /dev/null +++ b/src/sanctions/sanction-types.ts @@ -0,0 +1,11 @@ +export type SanctionType = "ban" | "mute" | "gag" | "silence" | "warning"; + +// Also the only types the public record counts: a warning is private to the +// player and staff. Lives outside SanctionsService so the search index can read +// it without importing rcon, which already imports the search service. +export const SERVER_ENFORCED_SANCTION_TYPES: SanctionType[] = [ + "ban", + "mute", + "gag", + "silence", +]; diff --git a/src/sanctions/sanctions.controller.ts b/src/sanctions/sanctions.controller.ts index 3ef51eb7..5ef2fc5c 100644 --- a/src/sanctions/sanctions.controller.ts +++ b/src/sanctions/sanctions.controller.ts @@ -2,7 +2,8 @@ import { Controller, Get, Param } from "@nestjs/common"; import { HasuraAction } from "src/hasura/hasura.controller"; import { User } from "src/auth/types/User"; import { isRoleAbove } from "src/utilities/isRoleAbove"; -import { SanctionsService, SanctionType } from "./sanctions.service"; +import { SanctionsService } from "./sanctions.service"; +import { SanctionType } from "./sanction-types"; @Controller("sanctions") export class SanctionsController { @@ -45,9 +46,10 @@ export class SanctionsController { serverId?: string | null; steam_id: string; type: SanctionType; + sanction_id?: string | null; user: User; }) { - const { serverId, steam_id, type, user } = data; + const { serverId, steam_id, type, sanction_id, user } = data; if (!user || !isRoleAbove(user.role, "moderator")) { throw Error("you are not allowed to remove sanctions"); @@ -57,6 +59,7 @@ export class SanctionsController { serverId, steamId: steam_id, type, + sanctionId: sanction_id, }); } diff --git a/src/sanctions/sanctions.service.spec.ts b/src/sanctions/sanctions.service.spec.ts new file mode 100644 index 00000000..8d66953a --- /dev/null +++ b/src/sanctions/sanctions.service.spec.ts @@ -0,0 +1,219 @@ +import { SanctionsService } from "./sanctions.service"; + +describe("SanctionsService", () => { + let service: SanctionsService; + let hasura: { query: jest.Mock; mutation: jest.Mock }; + let postgres: { query: jest.Mock }; + let rcon: { send: jest.Mock }; + let rconService: { connect: jest.Mock; disconnect: jest.Mock }; + let dedicatedServers: { + getServerPlayerList: jest.Mock; + resolveServerUserId: jest.Mock; + }; + + const steamId = "76561198000000001"; + const moderator = "76561198000000009"; + + const insertedSanction = () => + hasura.mutation.mock.calls + .map(([mutation]) => mutation.insert_player_sanctions_one) + .find(Boolean)?.__args.object; + + beforeEach(() => { + hasura = { + query: jest.fn().mockResolvedValue({ matches: [{ id: "match-1" }] }), + mutation: jest.fn(async (mutation: any) => + mutation.insert_player_sanctions_one + ? { insert_player_sanctions_one: { id: "sanction-1" } } + : {}, + ), + }; + postgres = { query: jest.fn().mockResolvedValue([]) }; + rcon = { send: jest.fn().mockResolvedValue("") }; + rconService = { + connect: jest.fn().mockResolvedValue(rcon), + disconnect: jest.fn().mockResolvedValue(undefined), + }; + dedicatedServers = { + getServerPlayerList: jest + .fn() + .mockResolvedValue([{ steam_id: steamId, name: "keith", userid: "4" }]), + resolveServerUserId: jest.fn(), + }; + + service = new SanctionsService( + { log: jest.fn(), warn: jest.fn(), error: jest.fn() } as any, + hasura as any, + postgres as any, + rconService as any, + dedicatedServers as any, + ); + }); + + describe("warnings", () => { + it.each([undefined, null, "", " "])( + "refuses a warning without a reason (%p)", + async (reason) => { + await expect( + service.sanctionServerPlayer({ + steamId, + type: "warning", + reason, + sanctionedBySteamId: moderator, + }), + ).rejects.toThrow("a reason is required for a warning"); + + expect(insertedSanction()).toBeUndefined(); + }, + ); + + it("saves the trimmed reason and never sets an end date", async () => { + const result = await service.sanctionServerPlayer({ + steamId, + type: "warning", + reason: " toxic in voice ", + duration: 60_000, + sanctionedBySteamId: moderator, + }); + + expect(insertedSanction()).toEqual({ + type: "warning", + player_steam_id: steamId, + sanctioned_by_steam_id: moderator, + reason: "toxic in voice", + remove_sanction_date: null, + }); + expect(result).toEqual({ + id: "sanction-1", + enforced: false, + message: "warning saved", + }); + }); + + it("never touches the server, even when one is named", async () => { + await service.sanctionServerPlayer({ + serverId: "server-1", + steamId, + type: "warning", + reason: "toxic in voice", + sanctionedBySteamId: moderator, + }); + + expect(dedicatedServers.getServerPlayerList).not.toHaveBeenCalled(); + expect(rconService.connect).not.toHaveBeenCalled(); + expect(rcon.send).not.toHaveBeenCalled(); + }); + + it("still syncs the server for an enforced sanction", async () => { + await service.sanctionServerPlayer({ + serverId: "server-1", + steamId, + type: "ban", + reason: "cheating", + sanctionedBySteamId: moderator, + }); + + expect(rconService.connect).toHaveBeenCalledWith("server-1"); + expect(rcon.send).toHaveBeenCalledWith("kickid 4 Banned"); + expect(rcon.send).toHaveBeenCalledWith("get_match"); + }); + }); + + describe("getActiveServerSanctions", () => { + it("only reads the types a server enforces", async () => { + await service.getActiveServerSanctions("server-1"); + + const [sql, params] = postgres.query.mock.calls[0]; + expect(sql).toContain("type = ANY($1::text[])"); + expect(params).toEqual([["ban", "mute", "gag", "silence"]]); + expect(params[0]).not.toContain("warning"); + }); + + it("maps the enforced types onto the plugin flags", async () => { + postgres.query.mockResolvedValueOnce([ + { player_steam_id: "1", type: "ban" }, + { player_steam_id: "2", type: "silence" }, + { player_steam_id: "3", type: "mute" }, + ]); + + expect(await service.getActiveServerSanctions("server-1")).toEqual([ + { steam_id: "1", is_banned: true, is_muted: false, is_gagged: false }, + { steam_id: "2", is_banned: false, is_muted: true, is_gagged: true }, + { steam_id: "3", is_banned: false, is_muted: true, is_gagged: false }, + ]); + }); + }); + + describe("unsanctionServerPlayer", () => { + it("removes only the named row when given a sanction id", async () => { + postgres.query.mockResolvedValueOnce([{ id: "sanction-7" }]); + + const result = await service.unsanctionServerPlayer({ + steamId, + type: "warning", + sanctionId: "sanction-7", + }); + + expect(postgres.query).toHaveBeenCalledTimes(1); + const [sql, params] = postgres.query.mock.calls[0]; + expect(sql).toContain("WHERE id = $1::uuid"); + expect(sql).toContain("AND player_steam_id = $2::bigint"); + expect(sql).toContain("AND deleted_at IS NULL"); + expect(params).toEqual(["sanction-7", steamId, "warning"]); + expect(result).toEqual({ + id: "sanction-7", + enforced: false, + message: "warning removed", + }); + }); + + it("says so when the named row is not there to remove", async () => { + postgres.query.mockResolvedValueOnce([]); + + await expect( + service.unsanctionServerPlayer({ + serverId: "server-1", + steamId, + type: "ban", + sanctionId: "sanction-7", + }), + ).rejects.toThrow("sanction not found"); + + expect(rconService.connect).not.toHaveBeenCalled(); + }); + + it("refuses to clear warnings by type alone", async () => { + await expect( + service.unsanctionServerPlayer({ steamId, type: "warning" }), + ).rejects.toThrow("a warning is removed by its sanction id"); + + expect(postgres.query).not.toHaveBeenCalled(); + }); + + it("never syncs the server when a warning is removed", async () => { + postgres.query.mockResolvedValueOnce([{ id: "sanction-7" }]); + + await service.unsanctionServerPlayer({ + serverId: "server-1", + steamId, + type: "warning", + sanctionId: "sanction-7", + }); + + expect(rconService.connect).not.toHaveBeenCalled(); + }); + + it("still clears an enforced type by type and syncs the server", async () => { + await service.unsanctionServerPlayer({ + serverId: "server-1", + steamId, + type: "mute", + }); + + const [sql, params] = postgres.query.mock.calls[0]; + expect(sql).not.toContain("WHERE id ="); + expect(params).toEqual([steamId, "mute"]); + expect(rcon.send).toHaveBeenCalledWith("get_match"); + }); + }); +}); diff --git a/src/sanctions/sanctions.service.ts b/src/sanctions/sanctions.service.ts index d92af920..22bfe0f9 100644 --- a/src/sanctions/sanctions.service.ts +++ b/src/sanctions/sanctions.service.ts @@ -3,8 +3,7 @@ import { HasuraService } from "src/hasura/hasura.service"; import { PostgresService } from "src/postgres/postgres.service"; import { RconService } from "src/rcon/rcon.service"; import { DedicatedServersService } from "src/dedicated-servers/dedicated-servers.service"; - -export type SanctionType = "ban" | "mute" | "gag" | "silence"; +import { SanctionType, SERVER_ENFORCED_SANCTION_TYPES } from "./sanction-types"; @Injectable() export class SanctionsService { @@ -21,6 +20,7 @@ export class SanctionsService { "mute", "gag", "silence", + "warning", ]; public async getActiveServerSanctions(serverId: string): Promise< @@ -37,7 +37,9 @@ export class SanctionsService { `SELECT player_steam_id::text AS player_steam_id, type FROM public.player_sanctions WHERE deleted_at IS NULL + AND type = ANY($1::text[]) AND (remove_sanction_date IS NULL OR remove_sanction_date > now())`, + [SERVER_ENFORCED_SANCTION_TYPES], ); const byPlayer: Record< @@ -84,6 +86,10 @@ export class SanctionsService { throw Error(`invalid sanction type ${type}`); } + if (type === "warning") { + return await this.warnPlayer(steamId, reason, sanctionedBySteamId); + } + let onServer: | { steam_id: string; name: string; userid: string | null } | undefined; @@ -139,33 +145,60 @@ export class SanctionsService { serverId?: string | null; steamId: string; type: SanctionType; + sanctionId?: string | null; }): Promise<{ id: string | null; enforced: boolean; message: string }> { - const { serverId, steamId, type } = params; + const { serverId, steamId, type, sanctionId } = params; if (!SanctionsService.SANCTION_TYPES.includes(type)) { throw Error(`invalid sanction type ${type}`); } - await this.postgres.query( - `UPDATE public.player_sanctions - SET deleted_at = now() - WHERE player_steam_id = $1::bigint - AND type = $2 - AND deleted_at IS NULL`, - [steamId, type], - ); + // Removal by type clears every active row of that type, which for warnings + // would wipe the player's whole record to retract one of them. + if (type === "warning" && !sanctionId) { + throw Error("a warning is removed by its sanction id"); + } + + let removedId: string | null = null; + + if (sanctionId) { + const removed = await this.postgres.query>( + `UPDATE public.player_sanctions + SET deleted_at = now() + WHERE id = $1::uuid + AND player_steam_id = $2::bigint + AND type = $3 + AND deleted_at IS NULL + RETURNING id`, + [sanctionId, steamId, type], + ); + removedId = removed.at(0)?.id ?? null; + + if (!removedId) { + throw Error("sanction not found"); + } + } else { + await this.postgres.query( + `UPDATE public.player_sanctions + SET deleted_at = now() + WHERE player_steam_id = $1::bigint + AND type = $2 + AND deleted_at IS NULL`, + [steamId, type], + ); + } let enforced = false; - let message = "sanction removed"; + let message = type === "warning" ? "warning removed" : "sanction removed"; - if (serverId) { + if (serverId && SERVER_ENFORCED_SANCTION_TYPES.includes(type)) { const result = await this.syncServer(serverId, null); enforced = result.enforced; message = result.message; } return { - id: null, + id: removedId, enforced, message, }; @@ -209,6 +242,40 @@ export class SanctionsService { } } + private async warnPlayer( + steamId: string, + reason: string | null | undefined, + sanctionedBySteamId: string, + ): Promise<{ id: string | null; enforced: boolean; message: string }> { + const trimmedReason = reason?.trim(); + if (!trimmedReason) { + throw Error("a reason is required for a warning"); + } + + await this.ensurePlayer(steamId); + + const { insert_player_sanctions_one } = await this.hasura.mutation({ + insert_player_sanctions_one: { + __args: { + object: { + type: "warning", + player_steam_id: steamId, + sanctioned_by_steam_id: sanctionedBySteamId, + reason: trimmedReason, + remove_sanction_date: null, + }, + }, + id: true, + }, + }); + + return { + id: insert_player_sanctions_one?.id ?? null, + enforced: false, + message: "warning saved", + }; + } + private async ensurePlayer(steamId: string, name?: string): Promise { await this.hasura.mutation({ insert_players: { diff --git a/src/type-sense/type-sense.sanctions.spec.ts b/src/type-sense/type-sense.sanctions.spec.ts new file mode 100644 index 00000000..1a008add --- /dev/null +++ b/src/type-sense/type-sense.sanctions.spec.ts @@ -0,0 +1,67 @@ +import { TypeSenseService } from "./type-sense.service"; + +describe("TypeSenseService player sanctions count", () => { + let hasura: { query: jest.Mock }; + let upsert: jest.Mock; + let service: TypeSenseService; + + beforeEach(() => { + hasura = { query: jest.fn() }; + upsert = jest.fn().mockResolvedValue({}); + + service = new TypeSenseService( + { log: jest.fn(), warn: jest.fn(), error: jest.fn() } as any, + { get: jest.fn() } as any, + hasura as any, + { sendServerMatchId: jest.fn() } as any, + { add: jest.fn() } as any, + { add: jest.fn() } as any, + { query: jest.fn() } as any, + ); + + (service as any).client = { + collections: jest.fn(() => ({ + documents: jest.fn(() => ({ upsert })), + })), + }; + + hasura.query + .mockResolvedValueOnce({ + players_by_pk: { + elo: {}, + name: "Player", + role: "user", + country: null, + avatar_url: null, + custom_avatar_url: null, + roster_image_url: null, + profile_url: null, + is_banned: false, + is_gagged: false, + is_muted: false, + teams: [], + last_sign_in_at: null, + wins: 0, + losses: 0, + total_matches: 0, + stats: { kills: 0, deaths: 0 }, + sanctions_aggregate: { aggregate: { count: 2 } }, + }, + }) + .mockResolvedValueOnce({ match_lineup_players: [] }); + }); + + it("counts only the enforced sanction types, never warnings", async () => { + await service.updatePlayer("76561198000000000"); + + const { sanctions_aggregate } = hasura.query.mock.calls[0][0].players_by_pk; + + expect(sanctions_aggregate.__args?.where).toEqual({ + type: { _in: ["ban", "mute", "gag", "silence"] }, + }); + expect(JSON.stringify(sanctions_aggregate)).not.toContain("warning"); + expect(upsert.mock.calls[0][0]).toEqual( + expect.objectContaining({ sanctions: 2 }), + ); + }); +}); diff --git a/src/type-sense/type-sense.service.ts b/src/type-sense/type-sense.service.ts index 77419341..7cfa8738 100644 --- a/src/type-sense/type-sense.service.ts +++ b/src/type-sense/type-sense.service.ts @@ -12,6 +12,7 @@ import { import { InjectQueue } from "@nestjs/bullmq"; import { Queue } from "bullmq"; import { PostgresService } from "../postgres/postgres.service"; +import { SERVER_ENFORCED_SANCTION_TYPES } from "../sanctions/sanction-types"; import { RefreshAllPlayersJob } from "./jobs/RefreshAllPlayers"; import { roleRank } from "../utilities/isRoleAbove"; @@ -537,6 +538,13 @@ export class TypeSenseService { deaths: true, }, sanctions_aggregate: { + __args: { + where: { + type: { + _in: SERVER_ENFORCED_SANCTION_TYPES, + }, + }, + }, aggregate: { count: true, }, diff --git a/test/notifications.spec.ts b/test/notifications.spec.ts index ee7ce58f..5e973846 100644 --- a/test/notifications.spec.ts +++ b/test/notifications.spec.ts @@ -559,6 +559,12 @@ describe("notifications (SQL-driven)", () => { expect(posted).toEqual([]); }); + it("keeps a warning between the player and staff", async () => { + await notify("PlayerWarning", "toxic in voice"); + + expect(posted).toEqual([]); + }); + it.each(["MatchFound", "AdminCall"])( "keeps a %s ring out of the staff channel", async (type) => { diff --git a/test/sanctions-warning.spec.ts b/test/sanctions-warning.spec.ts new file mode 100644 index 00000000..518a164d --- /dev/null +++ b/test/sanctions-warning.spec.ts @@ -0,0 +1,197 @@ +import { PostgresService } from "./../src/postgres/postgres.service"; +import { SanctionsService } from "./../src/sanctions/sanctions.service"; +import { Fixtures } from "./utils/fixtures"; +import { bootMigratedDb, SqlTestDb } from "./utils/sql-test-db"; + +// A warning is a note on the player's record: nothing that enforces a sanction +// may read it as one, and it never gets an end date. +describe("warning sanctions (SQL-driven)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + let fx: Fixtures; + + beforeAll(async () => { + db = await bootMigratedDb("SanctionsWarningTest"); + postgres = db.postgres; + fx = new Fixtures(postgres, 76561196200000000n); + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + beforeEach(async () => { + await postgres.query("DELETE FROM player_sanctions"); + await postgres.query("DELETE FROM players"); + }); + + const sanction = async ( + steamId: string, + type: string, + removeSanctionDate: string | null = null, + ) => { + const [row] = await postgres.query< + Array<{ id: string; remove_sanction_date: Date | null }> + >( + `INSERT INTO player_sanctions + (player_steam_id, type, reason, remove_sanction_date) + VALUES ($1::bigint, $2, 'reason', $3::timestamptz) + RETURNING id, remove_sanction_date`, + [steamId, type, removeSanctionDate], + ); + return row; + }; + + const flags = async (steamId: string) => { + const [row] = await postgres.query< + Array<{ + is_banned: boolean; + is_muted: boolean; + is_gagged: boolean; + is_admin_sanctioned: boolean; + banned_until: Date | null; + }> + >( + `SELECT public.is_banned(p) AS is_banned, + public.is_muted(p) AS is_muted, + public.is_gagged(p) AS is_gagged, + public.is_admin_sanctioned(p) AS is_admin_sanctioned, + public.banned_until(p) AS banned_until + FROM players p + WHERE p.steam_id = $1::bigint`, + [steamId], + ); + return row; + }; + + const service = () => + new SanctionsService( + { log: jest.fn(), warn: jest.fn(), error: jest.fn() } as any, + {} as any, + postgres, + {} as any, + {} as any, + ); + + it("enforces nothing on a player whose only sanction is a warning", async () => { + const steamId = await fx.player(); + const moderator = await fx.player(); + await postgres.query( + `INSERT INTO player_sanctions + (player_steam_id, type, reason, sanctioned_by_steam_id) + VALUES ($1::bigint, 'warning', 'toxic', $2::bigint)`, + [steamId, moderator], + ); + + expect(await flags(steamId)).toEqual({ + is_banned: false, + is_muted: false, + is_gagged: false, + is_admin_sanctioned: false, + banned_until: null, + }); + }); + + it("still enforces the real sanctions next to a warning", async () => { + const steamId = await fx.player(); + await sanction(steamId, "warning"); + await sanction(steamId, "silence"); + + expect(await flags(steamId)).toMatchObject({ + is_banned: false, + is_muted: true, + is_gagged: true, + }); + }); + + it("drops an end date given to a warning on insert", async () => { + const steamId = await fx.player(); + const row = await sanction( + steamId, + "warning", + new Date(Date.now() + 86_400_000).toISOString(), + ); + + expect(row.remove_sanction_date).toBeNull(); + }); + + it("drops an end date given to a warning on update", async () => { + const steamId = await fx.player(); + const { id } = await sanction(steamId, "warning"); + + await postgres.query( + `UPDATE player_sanctions + SET remove_sanction_date = now() + interval '1 day' + WHERE id = $1`, + [id], + ); + + const [row] = await postgres.query< + Array<{ remove_sanction_date: Date | null }> + >("SELECT remove_sanction_date FROM player_sanctions WHERE id = $1", [id]); + expect(row.remove_sanction_date).toBeNull(); + }); + + it("leaves the end date of every other type alone", async () => { + const steamId = await fx.player(); + const row = await sanction( + steamId, + "mute", + new Date(Date.now() + 86_400_000).toISOString(), + ); + + expect(row.remove_sanction_date).not.toBeNull(); + }); + + it("never hands a warning to the game server", async () => { + const warned = await fx.player(); + const muted = await fx.player(); + await sanction(warned, "warning"); + await sanction(muted, "mute"); + + expect(await service().getActiveServerSanctions("server-1")).toEqual([ + { steam_id: muted, is_banned: false, is_muted: true, is_gagged: false }, + ]); + }); + + it("removes one warning by id and leaves the rest of the record", async () => { + const steamId = await fx.player(); + const first = await sanction(steamId, "warning"); + const second = await sanction(steamId, "warning"); + + const result = await service().unsanctionServerPlayer({ + steamId, + type: "warning", + sanctionId: first.id, + }); + + expect(result.id).toBe(first.id); + const active = await postgres.query>( + `SELECT id FROM player_sanctions + WHERE player_steam_id = $1::bigint + AND deleted_at IS NULL`, + [steamId], + ); + expect(active.map(({ id }) => id)).toEqual([second.id]); + }); + + it("will not remove somebody else's sanction by id", async () => { + const steamId = await fx.player(); + const other = await fx.player(); + const theirs = await sanction(other, "warning"); + + await expect( + service().unsanctionServerPlayer({ + steamId, + type: "warning", + sanctionId: theirs.id, + }), + ).rejects.toThrow("sanction not found"); + + const [row] = await postgres.query>( + "SELECT deleted_at FROM player_sanctions WHERE id = $1", + [theirs.id], + ); + expect(row.deleted_at).toBeNull(); + }); +});