xpt-shared-types 1.16.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,10 @@ export type AdminActionTargetType = "user" | "tournament" | "match" | "transacti
4
4
  export type FaqCategory = "gettingStarted" | "tournamentsMatches" | "hosting" | "walletCoins" | "account";
5
5
  /** `Friendship.status` */
6
6
  export type FriendshipStatus = "pending" | "accepted";
7
+ /** `GameAccount.code` */
8
+ export type GameAccountCode = "steam" | "psn" | "xbox" | "battlenet" | "riot" | "epic" | "ea" | "activision" | "ubisoft" | "nintendo" | "fightcade" | "apple" | "googleplay";
9
+ /** `GameAccount.verification` */
10
+ export type GameAccountVerification = "none" | "format";
7
11
  /** `Game.genre` */
8
12
  export type GameGenre = "FPS" | "MOBA" | "Battle Royale" | "Fighting (FGC)" | "Sports" | "Strategy/RTS" | "Sim Racing" | "Card Game" | "Retro" | "Other";
9
13
  /** `GameRequest.customLobbies` */
@@ -28,6 +32,8 @@ export type MatchStreamPlatform = "twitch" | "youtube" | "kick" | "other";
28
32
  export type MatchWinnerSlot = "home" | "away";
29
33
  /** `Platform.code` */
30
34
  export type PlatformCode = "pc" | "playstation" | "xbox" | "nintendo" | "mobile";
35
+ /** `PlayerBan.scope` */
36
+ export type PlayerBanScope = "tournament" | "host";
31
37
  /** `Referral.status` */
32
38
  export type ReferralStatus = "pending" | "completed";
33
39
  /** `Team.current_status` */
@@ -1,5 +1,5 @@
1
1
  import type { BlocksContent, MediaInput, RelationInput } from '../contracts';
2
- import type { AdminActionTargetType, FaqCategory, FriendshipStatus, GameGenre, GameRequestCustomLobbies, GameRequestGenre, GameRequestTeamPlay, GameVisibility, MatchDisputeReason, MatchLobbyStatus, MatchResultType, MatchRound, MatchStreamPlatform, MatchWinnerSlot, PlatformCode, ReferralStatus, TeamCurrentStatus, TeamInviteStatus, TeamInviteTeamRole, TeamPlayerTeamRole, TournamentAccess, TournamentCurrentStatus, TournamentInviteStatus, TournamentJoinRequestEntryType, TournamentJoinRequestStatus, TournamentParticipantEntryType, TournamentParticipantStatus, TournamentRoleRole, TournamentTeamSize, TournamentType, TournamentVisibility, UserTransactionStripeStatus, UserTransactionType } from './enums';
2
+ import type { AdminActionTargetType, FaqCategory, FriendshipStatus, GameAccountCode, GameAccountVerification, GameGenre, GameRequestCustomLobbies, GameRequestGenre, GameRequestTeamPlay, GameVisibility, MatchDisputeReason, MatchLobbyStatus, MatchResultType, MatchRound, MatchStreamPlatform, MatchWinnerSlot, PlatformCode, PlayerBanScope, ReferralStatus, TeamCurrentStatus, TeamInviteStatus, TeamInviteTeamRole, TeamPlayerTeamRole, TournamentAccess, TournamentCurrentStatus, TournamentInviteStatus, TournamentJoinRequestEntryType, TournamentJoinRequestStatus, TournamentParticipantEntryType, TournamentParticipantStatus, TournamentRoleRole, TournamentTeamSize, TournamentType, TournamentVisibility, UserTransactionStripeStatus, UserTransactionType } from './enums';
3
3
  /** Write payload for `about`. */
4
4
  export interface AboutInput {
5
5
  heroTitle?: string;
@@ -75,6 +75,8 @@ export interface GameInput {
75
75
  /** Write payload for `game-account`. */
76
76
  export interface GameAccountInput {
77
77
  name?: string;
78
+ code?: GameAccountCode;
79
+ verification?: GameAccountVerification;
78
80
  tournaments?: RelationInput | RelationInput[];
79
81
  user_game_accounts?: RelationInput | RelationInput[];
80
82
  imgThumb?: MediaInput;
@@ -187,6 +189,18 @@ export interface PlatformInput {
187
189
  games?: RelationInput | RelationInput[];
188
190
  game_accounts?: RelationInput | RelationInput[];
189
191
  }
192
+ /** Write payload for `player-ban`. */
193
+ export interface PlayerBanInput {
194
+ scope?: PlayerBanScope;
195
+ tournament?: RelationInput;
196
+ host?: RelationInput;
197
+ users_permissions_user?: RelationInput;
198
+ bannedBy?: RelationInput;
199
+ reason?: string;
200
+ expiresAt?: string;
201
+ liftedAt?: string;
202
+ liftedBy?: RelationInput;
203
+ }
190
204
  /** Write payload for `preset-avatar`. */
191
205
  export interface PresetAvatarInput {
192
206
  image?: MediaInput;
@@ -1,5 +1,5 @@
1
1
  import type { BlocksContent, StrapiDocument, StrapiMedia, UserRole } from '../contracts';
2
- import type { AdminActionTargetType, FaqCategory, FriendshipStatus, GameGenre, GameRequestCustomLobbies, GameRequestGenre, GameRequestTeamPlay, GameVisibility, MatchDisputeReason, MatchLobbyStatus, MatchResultType, MatchRound, MatchStreamPlatform, MatchWinnerSlot, PlatformCode, ReferralStatus, TeamCurrentStatus, TeamInviteStatus, TeamInviteTeamRole, TeamPlayerTeamRole, TournamentAccess, TournamentCurrentStatus, TournamentInviteStatus, TournamentJoinRequestEntryType, TournamentJoinRequestStatus, TournamentParticipantEntryType, TournamentParticipantStatus, TournamentRoleRole, TournamentTeamSize, TournamentType, TournamentVisibility, UserTransactionStripeStatus, UserTransactionType } from './enums';
2
+ import type { AdminActionTargetType, FaqCategory, FriendshipStatus, GameAccountCode, GameAccountVerification, GameGenre, GameRequestCustomLobbies, GameRequestGenre, GameRequestTeamPlay, GameVisibility, MatchDisputeReason, MatchLobbyStatus, MatchResultType, MatchRound, MatchStreamPlatform, MatchWinnerSlot, PlatformCode, PlayerBanScope, ReferralStatus, TeamCurrentStatus, TeamInviteStatus, TeamInviteTeamRole, TeamPlayerTeamRole, TournamentAccess, TournamentCurrentStatus, TournamentInviteStatus, TournamentJoinRequestEntryType, TournamentJoinRequestStatus, TournamentParticipantEntryType, TournamentParticipantStatus, TournamentRoleRole, TournamentTeamSize, TournamentType, TournamentVisibility, UserTransactionStripeStatus, UserTransactionType } from './enums';
3
3
  /** `about` */
4
4
  export interface About extends StrapiDocument {
5
5
  heroTitle?: string | null;
@@ -75,6 +75,8 @@ export interface Game extends StrapiDocument {
75
75
  /** `game-account` */
76
76
  export interface GameAccount extends StrapiDocument {
77
77
  name?: string | null;
78
+ code?: GameAccountCode | null;
79
+ verification?: GameAccountVerification | null;
78
80
  tournaments?: Tournament[];
79
81
  user_game_accounts?: UserGameAccount[];
80
82
  imgThumb?: StrapiMedia | null;
@@ -187,6 +189,18 @@ export interface Platform extends StrapiDocument {
187
189
  games?: Game[];
188
190
  game_accounts?: GameAccount[];
189
191
  }
192
+ /** `player-ban` */
193
+ export interface PlayerBan extends StrapiDocument {
194
+ scope?: PlayerBanScope | null;
195
+ tournament?: Tournament | null;
196
+ host?: User | null;
197
+ users_permissions_user?: User | null;
198
+ bannedBy?: User | null;
199
+ reason?: string | null;
200
+ expiresAt?: string | null;
201
+ liftedAt?: string | null;
202
+ liftedBy?: User | null;
203
+ }
190
204
  /** `preset-avatar` */
191
205
  export interface PresetAvatar extends StrapiDocument {
192
206
  image?: StrapiMedia | null;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * What a player's handle on each account network looks like.
3
+ *
4
+ * A handle is the one thing opponents actually exchange — the PSN ID, the
5
+ * BattleTag, the Riot ID — so a typo in it is a match that cannot start. Until
6
+ * these rules existed one regex covered every network, and it happily accepted
7
+ * `Name#1234` for PSN (where `#` is not allowed) while rejecting nothing that a
8
+ * real PSN or Xbox rule would.
9
+ *
10
+ * None of this proves ownership. A network whose handles can be linked by
11
+ * OAuth (Steam, Battle.net, Xbox) gets a `linked` tier later; this file is the
12
+ * `format` tier that every network can have today. Which tier applies is a
13
+ * property of the catalogue row (`GameAccount.verification`), set by editors,
14
+ * so a rule that turns out wrong for some network can be switched off without a
15
+ * deploy: set the row to `none` and the generic rule applies.
16
+ *
17
+ * Lives here because the settings page validates as the player types and the
18
+ * backend validates again on write. Two copies of a regex is two chances for a
19
+ * handle to pass one and fail the other.
20
+ *
21
+ * Pure on purpose: no I/O, no profanity list (each app keeps its own), so the
22
+ * rules are unit-tested directly (xpt-strapi
23
+ * `tests/unit/utils/gameAccountHandle.test.ts`).
24
+ */
25
+ import type { GameAccountCode, GameAccountVerification } from '../generated/enums';
26
+ export interface HandleRule {
27
+ /** The network this rule is for; absent on the generic fallback. */
28
+ code?: GameAccountCode;
29
+ /** What the field is called in copy: "PSN online ID", "BattleTag". */
30
+ label: string;
31
+ /** The field's placeholder. */
32
+ placeholder: string;
33
+ /** One sentence under the field saying what a valid value looks like. */
34
+ hint: string;
35
+ /** Applied to the normalized value. */
36
+ pattern: RegExp;
37
+ min: number;
38
+ max: number;
39
+ /**
40
+ * Rewrites the trimmed input before `pattern` is applied: a Steam profile
41
+ * URL down to its id, a bare Nintendo friend code into `SW-xxxx-xxxx-xxxx`.
42
+ * The result is what gets stored.
43
+ */
44
+ normalize?: (value: string) => string;
45
+ }
46
+ export type HandleCheck = {
47
+ ok: true;
48
+ value: string;
49
+ } | {
50
+ ok: false;
51
+ message: string;
52
+ };
53
+ /** `error.details.name` values the game-account write endpoints raise. */
54
+ export type GameAccountErrorName = 'HandleInvalid' | 'HandleTaken';
55
+ /**
56
+ * The rule every network gets when it has no stricter one. Also the backstop
57
+ * the backend applies to every write regardless of network, so every rule
58
+ * below must produce a value that passes this one.
59
+ *
60
+ * Letters are `\p{L}` rather than `A-Z`: BattleTags and Riot IDs allow accented
61
+ * names, and a rule that rejected "Ærøn#1234" would be wrong, not strict.
62
+ */
63
+ export declare const GENERIC_HANDLE_RULE: HandleRule;
64
+ /**
65
+ * Per-network rules. Initial values, and product decisions rather than facts:
66
+ * each is the documented shape as of writing, kept a little looser than the
67
+ * network's own signup validator so a real handle is never refused.
68
+ */
69
+ export declare const HANDLE_RULES: Partial<Record<GameAccountCode, HandleRule>>;
70
+ /**
71
+ * The rule a network's handles are checked against: its own when the
72
+ * catalogue row asks for format checks and a rule exists for its code,
73
+ * otherwise the generic one.
74
+ */
75
+ export declare function handleRuleFor(network: {
76
+ code?: GameAccountCode | string | null;
77
+ verification?: GameAccountVerification | string | null;
78
+ } | null | undefined): HandleRule;
79
+ /** True when the code has a rule of its own, so `format` means something. */
80
+ export declare function hasHandleRule(code: string | null | undefined): boolean;
81
+ /**
82
+ * Checks `raw` against `rule` and returns the value to store: trimmed, runs of
83
+ * whitespace collapsed to one space, then the rule's own normalization.
84
+ *
85
+ * The message is the one the player reads, so it says what a valid value looks
86
+ * like rather than which check failed.
87
+ */
88
+ export declare function checkHandle(raw: string | null | undefined, rule: HandleRule): HandleCheck;
89
+ /** The key two handles are compared on: same network, same letters, any case. */
90
+ export declare function normalizeHandleKey(value: string): string;
@@ -0,0 +1,203 @@
1
+ "use strict";
2
+ /**
3
+ * What a player's handle on each account network looks like.
4
+ *
5
+ * A handle is the one thing opponents actually exchange — the PSN ID, the
6
+ * BattleTag, the Riot ID — so a typo in it is a match that cannot start. Until
7
+ * these rules existed one regex covered every network, and it happily accepted
8
+ * `Name#1234` for PSN (where `#` is not allowed) while rejecting nothing that a
9
+ * real PSN or Xbox rule would.
10
+ *
11
+ * None of this proves ownership. A network whose handles can be linked by
12
+ * OAuth (Steam, Battle.net, Xbox) gets a `linked` tier later; this file is the
13
+ * `format` tier that every network can have today. Which tier applies is a
14
+ * property of the catalogue row (`GameAccount.verification`), set by editors,
15
+ * so a rule that turns out wrong for some network can be switched off without a
16
+ * deploy: set the row to `none` and the generic rule applies.
17
+ *
18
+ * Lives here because the settings page validates as the player types and the
19
+ * backend validates again on write. Two copies of a regex is two chances for a
20
+ * handle to pass one and fail the other.
21
+ *
22
+ * Pure on purpose: no I/O, no profanity list (each app keeps its own), so the
23
+ * rules are unit-tested directly (xpt-strapi
24
+ * `tests/unit/utils/gameAccountHandle.test.ts`).
25
+ */
26
+ Object.defineProperty(exports, "__esModule", { value: true });
27
+ exports.HANDLE_RULES = exports.GENERIC_HANDLE_RULE = void 0;
28
+ exports.handleRuleFor = handleRuleFor;
29
+ exports.hasHandleRule = hasHandleRule;
30
+ exports.checkHandle = checkHandle;
31
+ exports.normalizeHandleKey = normalizeHandleKey;
32
+ /**
33
+ * The rule every network gets when it has no stricter one. Also the backstop
34
+ * the backend applies to every write regardless of network, so every rule
35
+ * below must produce a value that passes this one.
36
+ *
37
+ * Letters are `\p{L}` rather than `A-Z`: BattleTags and Riot IDs allow accented
38
+ * names, and a rule that rejected "Ærøn#1234" would be wrong, not strict.
39
+ */
40
+ exports.GENERIC_HANDLE_RULE = {
41
+ label: 'Game ID',
42
+ placeholder: 'Game ID',
43
+ hint: 'Enter your ID exactly as it appears in-game. Letters, numbers, spaces and _ . # - are allowed.',
44
+ pattern: /^[\p{L}\p{N}_ .#-]+$/u,
45
+ min: 3,
46
+ max: 32,
47
+ };
48
+ const STEAM_PROFILE_URL = /^(?:https?:\/\/)?(?:www\.)?steamcommunity\.com\/(?:id|profiles)\/([^/?#\s]+)\/?(?:[?#].*)?$/i;
49
+ /**
50
+ * Per-network rules. Initial values, and product decisions rather than facts:
51
+ * each is the documented shape as of writing, kept a little looser than the
52
+ * network's own signup validator so a real handle is never refused.
53
+ */
54
+ exports.HANDLE_RULES = {
55
+ psn: {
56
+ code: 'psn',
57
+ label: 'PSN online ID',
58
+ placeholder: 'PSN online ID',
59
+ hint: '3 to 16 letters, numbers, hyphens or underscores, starting with a letter. No # tag.',
60
+ pattern: /^[A-Za-z][A-Za-z0-9_-]{2,15}$/,
61
+ min: 3,
62
+ max: 16,
63
+ },
64
+ xbox: {
65
+ code: 'xbox',
66
+ label: 'Gamertag',
67
+ placeholder: 'Gamertag or Gamertag#1234',
68
+ hint: 'Letters, numbers and single spaces, with the #1234 suffix if your gamertag has one.',
69
+ pattern: /^[A-Za-z0-9]+(?: [A-Za-z0-9]+)*(?:#\d{3,4})?$/,
70
+ min: 3,
71
+ max: 20,
72
+ },
73
+ battlenet: {
74
+ code: 'battlenet',
75
+ label: 'BattleTag',
76
+ placeholder: 'Name#1234',
77
+ hint: 'Your BattleTag including the number, e.g. Name#1234.',
78
+ pattern: /^\p{L}[\p{L}\p{N}]{2,11}#\d{4,7}$/u,
79
+ min: 8,
80
+ max: 20,
81
+ },
82
+ riot: {
83
+ code: 'riot',
84
+ label: 'Riot ID',
85
+ placeholder: 'GameName#TAG',
86
+ hint: 'Your Riot ID including the tagline, e.g. GameName#TAG.',
87
+ pattern: /^[\p{L}\p{N}](?:[\p{L}\p{N} ]{1,14}[\p{L}\p{N}])?#[\p{L}\p{N}]{3,5}$/u,
88
+ min: 7,
89
+ max: 22,
90
+ },
91
+ epic: {
92
+ code: 'epic',
93
+ label: 'Epic display name',
94
+ placeholder: 'Epic display name',
95
+ hint: '3 to 16 letters, numbers, spaces or - _ . as shown on your Epic account.',
96
+ pattern: /^[\p{L}\p{N}][\p{L}\p{N} ._-]{1,14}[\p{L}\p{N}._-]$/u,
97
+ min: 3,
98
+ max: 16,
99
+ },
100
+ ea: {
101
+ code: 'ea',
102
+ label: 'EA ID',
103
+ placeholder: 'EA ID',
104
+ hint: '4 to 16 letters, numbers, hyphens or underscores.',
105
+ pattern: /^[A-Za-z0-9_-]{4,16}$/,
106
+ min: 4,
107
+ max: 16,
108
+ },
109
+ activision: {
110
+ code: 'activision',
111
+ label: 'Activision ID',
112
+ placeholder: 'Name#1234567',
113
+ hint: 'Your Activision ID including the number, e.g. Name#1234567.',
114
+ pattern: /^[A-Za-z0-9 _-]{2,16}#\d{4,8}$/,
115
+ min: 7,
116
+ max: 25,
117
+ },
118
+ ubisoft: {
119
+ code: 'ubisoft',
120
+ label: 'Ubisoft username',
121
+ placeholder: 'Ubisoft username',
122
+ hint: '3 to 15 letters, numbers or - _ . as shown in Ubisoft Connect.',
123
+ pattern: /^[A-Za-z0-9._-]{3,15}$/,
124
+ min: 3,
125
+ max: 15,
126
+ },
127
+ steam: {
128
+ code: 'steam',
129
+ label: 'Steam ID',
130
+ placeholder: 'Friend code or profile URL',
131
+ hint: 'Your Steam friend code (Friends > Add a Friend), your SteamID64, or your steamcommunity.com profile URL.',
132
+ // A profile URL is the easiest thing for a player to paste; what gets
133
+ // stored is the id or custom name at the end of it.
134
+ normalize: (value) => value.replace(STEAM_PROFILE_URL, '$1'),
135
+ pattern: /^[A-Za-z0-9_-]{3,32}$/,
136
+ min: 3,
137
+ max: 32,
138
+ },
139
+ nintendo: {
140
+ code: 'nintendo',
141
+ label: 'Nintendo friend code',
142
+ placeholder: 'SW-0000-0000-0000',
143
+ hint: 'Your 12-digit Switch friend code, e.g. SW-1234-5678-9012.',
144
+ normalize: (value) => {
145
+ const digits = value.replace(/^sw/i, '').replace(/[\s-]/g, '');
146
+ return /^\d{12}$/.test(digits)
147
+ ? `SW-${digits.slice(0, 4)}-${digits.slice(4, 8)}-${digits.slice(8)}`
148
+ : value;
149
+ },
150
+ pattern: /^SW-\d{4}-\d{4}-\d{4}$/,
151
+ min: 17,
152
+ max: 17,
153
+ },
154
+ // fightcade, apple and googleplay have no documented handle shape and stay
155
+ // on the generic rule.
156
+ };
157
+ /**
158
+ * The rule a network's handles are checked against: its own when the
159
+ * catalogue row asks for format checks and a rule exists for its code,
160
+ * otherwise the generic one.
161
+ */
162
+ function handleRuleFor(network) {
163
+ var _a;
164
+ if ((network === null || network === void 0 ? void 0 : network.verification) !== 'format' || !network.code) {
165
+ return exports.GENERIC_HANDLE_RULE;
166
+ }
167
+ return (_a = exports.HANDLE_RULES[network.code]) !== null && _a !== void 0 ? _a : exports.GENERIC_HANDLE_RULE;
168
+ }
169
+ /** True when the code has a rule of its own, so `format` means something. */
170
+ function hasHandleRule(code) {
171
+ return !!code && Object.prototype.hasOwnProperty.call(exports.HANDLE_RULES, code);
172
+ }
173
+ /**
174
+ * Checks `raw` against `rule` and returns the value to store: trimmed, runs of
175
+ * whitespace collapsed to one space, then the rule's own normalization.
176
+ *
177
+ * The message is the one the player reads, so it says what a valid value looks
178
+ * like rather than which check failed.
179
+ */
180
+ function checkHandle(raw, rule) {
181
+ const collapsed = (raw !== null && raw !== void 0 ? raw : '').trim().replace(/\s+/g, ' ');
182
+ if (!collapsed) {
183
+ return { ok: false, message: `Please enter your ${rule.label}` };
184
+ }
185
+ const value = rule.normalize ? rule.normalize(collapsed) : collapsed;
186
+ if (value.length < rule.min) {
187
+ return { ok: false, message: `${rule.label} is too short. ${rule.hint}` };
188
+ }
189
+ if (value.length > rule.max) {
190
+ return { ok: false, message: `${rule.label} is too long. ${rule.hint}` };
191
+ }
192
+ if (!rule.pattern.test(value)) {
193
+ return {
194
+ ok: false,
195
+ message: `That doesn't look like a valid ${rule.label}. ${rule.hint}`,
196
+ };
197
+ }
198
+ return { ok: true, value };
199
+ }
200
+ /** The key two handles are compared on: same network, same letters, any case. */
201
+ function normalizeHandleKey(value) {
202
+ return value.trim().replace(/\s+/g, ' ').toLowerCase();
203
+ }
@@ -2,8 +2,10 @@
2
2
  * Hand-written types that are not derived from a Strapi content type.
3
3
  */
4
4
  export * from './country';
5
+ export * from './gameAccountHandle';
5
6
  export * from './lobby';
6
7
  export * from './notification';
8
+ export * from './playerBan';
7
9
  export * from './tournamentEntry';
8
10
  export * from './tournamentInvite';
9
11
  export * from './tournamentJoinRequest';
@@ -18,8 +18,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
18
18
  * Hand-written types that are not derived from a Strapi content type.
19
19
  */
20
20
  __exportStar(require("./country"), exports);
21
+ __exportStar(require("./gameAccountHandle"), exports);
21
22
  __exportStar(require("./lobby"), exports);
22
23
  __exportStar(require("./notification"), exports);
24
+ __exportStar(require("./playerBan"), exports);
23
25
  __exportStar(require("./tournamentEntry"), exports);
24
26
  __exportStar(require("./tournamentInvite"), exports);
25
27
  __exportStar(require("./tournamentJoinRequest"), exports);
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Player bans — the contract between `utils/playerBan.ts` in xpt-strapi and
3
+ * the ban dialog, ban list and host-bans page in xpt-client.
4
+ *
5
+ * Two scopes (`PlayerBanScope`, generated): **tournament** keeps a player out
6
+ * of one event and is set by its owner or an admin; **host** keeps them out
7
+ * of every tournament one host owns, now and later, and only that host sets
8
+ * it. The platform level is the account block, not a ban.
9
+ *
10
+ * A ban is active until it is lifted or its `expiresAt` passes; `null` is
11
+ * permanent. Lifted bans are kept, so a list can show history.
12
+ *
13
+ * Routes:
14
+ * - `GET|POST /tournaments/:id/bans`, `DELETE /tournaments/:id/bans/:userId`
15
+ * - `GET|POST /host-bans`, `DELETE /host-bans/:userId`
16
+ * Both GETs take `?includeLifted=true`.
17
+ */
18
+ import type { PlayerBanScope } from '../generated/enums';
19
+ export declare const MAX_BAN_REASON_LENGTH = 500;
20
+ /** A ban as the list and ban routes return it. */
21
+ export interface PlayerBanSummary {
22
+ documentId: string;
23
+ scope: PlayerBanScope;
24
+ reason: string | null;
25
+ createdAt: string | null;
26
+ /** Null for a permanent ban. */
27
+ expiresAt: string | null;
28
+ liftedAt: string | null;
29
+ /** Not lifted and not expired. */
30
+ active: boolean;
31
+ users_permissions_user: {
32
+ id: number;
33
+ documentId: string | null;
34
+ username: string | null;
35
+ image: unknown;
36
+ } | null;
37
+ bannedBy: {
38
+ id: number;
39
+ username: string | null;
40
+ } | null;
41
+ liftedBy: {
42
+ id: number;
43
+ username: string | null;
44
+ } | null;
45
+ /** Set on tournament-scope bans. */
46
+ tournament: {
47
+ documentId: string;
48
+ title: string | null;
49
+ } | null;
50
+ /**
51
+ * Whether the viewer may lift it. On a tournament's list a host ban is
52
+ * liftable only by the owner — an admin sees it but cannot lift it.
53
+ */
54
+ canLift: boolean;
55
+ }
56
+ /** `POST /tournaments/:id/bans` and `POST /host-bans`. */
57
+ export interface BanPlayerBody {
58
+ userId: number;
59
+ reason?: string | null;
60
+ /** ISO date in the future; omit or null for permanent. */
61
+ expiresAt?: string | null;
62
+ /**
63
+ * Also withdraw the entry the player registered (with its refund), while
64
+ * the tournament still takes entries. Host bans apply it across all of the
65
+ * host's open tournaments.
66
+ */
67
+ removeEntry?: boolean;
68
+ }
69
+ /** Why `removeEntry` left an entry where it was. */
70
+ export type BanRemovalSkip =
71
+ /** No entry in this tournament. Never returned for host bans. */
72
+ 'notRegistered'
73
+ /** On a team someone else registered: the team's leader must swap them out. */
74
+ | 'notRegistrant'
75
+ /** Past check-in; the bracket is seeded. */
76
+ | 'entryLocked'
77
+ /** The withdrawal refused; `message` says why. */
78
+ | 'failed';
79
+ export interface BanRemoval {
80
+ tournamentDocumentId: string;
81
+ tournamentTitle: string | null;
82
+ removed: boolean;
83
+ /** Coins returned to whoever paid. */
84
+ refunded: number;
85
+ skipped?: BanRemovalSkip;
86
+ message?: string;
87
+ }
88
+ /** The `data` of a successful ban. */
89
+ export interface BanResult {
90
+ ban: PlayerBanSummary;
91
+ /** Pending join requests declined and refunded because of the ban. */
92
+ declinedRequests: number;
93
+ /** Empty unless `removeEntry` was asked for. */
94
+ removals: BanRemoval[];
95
+ }
96
+ /** The durations the ban dialog offers. */
97
+ export declare const BAN_DURATIONS: readonly [{
98
+ readonly key: "1d";
99
+ readonly label: "24 hours";
100
+ readonly days: 1;
101
+ }, {
102
+ readonly key: "7d";
103
+ readonly label: "7 days";
104
+ readonly days: 7;
105
+ }, {
106
+ readonly key: "30d";
107
+ readonly label: "30 days";
108
+ readonly days: 30;
109
+ }, {
110
+ readonly key: "permanent";
111
+ readonly label: "Permanent";
112
+ readonly days: null;
113
+ }];
114
+ export type BanDurationKey = (typeof BAN_DURATIONS)[number]['key'];
115
+ /** `expiresAt` for a duration, or null for permanent. */
116
+ export declare function banExpiryFor(key: BanDurationKey, now?: Date): string | null;
117
+ export declare function isBanActive(ban: {
118
+ liftedAt?: string | null;
119
+ expiresAt?: string | null;
120
+ }, now?: Date): boolean;
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ /**
3
+ * Player bans — the contract between `utils/playerBan.ts` in xpt-strapi and
4
+ * the ban dialog, ban list and host-bans page in xpt-client.
5
+ *
6
+ * Two scopes (`PlayerBanScope`, generated): **tournament** keeps a player out
7
+ * of one event and is set by its owner or an admin; **host** keeps them out
8
+ * of every tournament one host owns, now and later, and only that host sets
9
+ * it. The platform level is the account block, not a ban.
10
+ *
11
+ * A ban is active until it is lifted or its `expiresAt` passes; `null` is
12
+ * permanent. Lifted bans are kept, so a list can show history.
13
+ *
14
+ * Routes:
15
+ * - `GET|POST /tournaments/:id/bans`, `DELETE /tournaments/:id/bans/:userId`
16
+ * - `GET|POST /host-bans`, `DELETE /host-bans/:userId`
17
+ * Both GETs take `?includeLifted=true`.
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.BAN_DURATIONS = exports.MAX_BAN_REASON_LENGTH = void 0;
21
+ exports.banExpiryFor = banExpiryFor;
22
+ exports.isBanActive = isBanActive;
23
+ exports.MAX_BAN_REASON_LENGTH = 500;
24
+ /** The durations the ban dialog offers. */
25
+ exports.BAN_DURATIONS = [
26
+ { key: '1d', label: '24 hours', days: 1 },
27
+ { key: '7d', label: '7 days', days: 7 },
28
+ { key: '30d', label: '30 days', days: 30 },
29
+ { key: 'permanent', label: 'Permanent', days: null },
30
+ ];
31
+ /** `expiresAt` for a duration, or null for permanent. */
32
+ function banExpiryFor(key, now = new Date()) {
33
+ var _a, _b;
34
+ const days = (_b = (_a = exports.BAN_DURATIONS.find((d) => d.key === key)) === null || _a === void 0 ? void 0 : _a.days) !== null && _b !== void 0 ? _b : null;
35
+ return days == null
36
+ ? null
37
+ : new Date(now.getTime() + days * 86400000).toISOString();
38
+ }
39
+ function isBanActive(ban, now = new Date()) {
40
+ if (ban.liftedAt)
41
+ return false;
42
+ if (!ban.expiresAt)
43
+ return true;
44
+ return new Date(ban.expiresAt).getTime() > now.getTime();
45
+ }
@@ -36,7 +36,9 @@ export type TournamentStaffAction =
36
36
  /** Record scores, start matches, edit match details. */
37
37
  | 'manageMatches'
38
38
  /** Watch any lobby and post as staff in its chat. */
39
- | 'enterLobby';
39
+ | 'enterLobby'
40
+ /** Ban players from this tournament and lift those bans. */
41
+ | 'manageBans';
40
42
  export declare const TOURNAMENT_STAFF_CAPABILITIES: Record<TournamentStaffAction, readonly TournamentStaffRole[]>;
41
43
  export declare const TOURNAMENT_STAFF_ROLE_LABELS: Record<TournamentStaffRole, string>;
42
44
  export declare const tournamentStaffRoleLabel: (role: string | null | undefined) => string;
@@ -36,6 +36,7 @@ exports.TOURNAMENT_STAFF_CAPABILITIES = {
36
36
  reviewJoinRequests: ADMIN_UP,
37
37
  resolveDispute: ADMIN_UP,
38
38
  seedBracket: ADMIN_UP,
39
+ manageBans: ADMIN_UP,
39
40
  manageMatches: ALL_STAFF,
40
41
  enterLobby: ALL_STAFF,
41
42
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xpt-shared-types",
3
- "version": "1.16.0",
3
+ "version": "1.18.0",
4
4
  "description": "Shared types and data for XPT projects",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -22,6 +22,27 @@ export type FriendshipStatus =
22
22
  | "pending"
23
23
  | "accepted";
24
24
 
25
+ /** `GameAccount.code` */
26
+ export type GameAccountCode =
27
+ | "steam"
28
+ | "psn"
29
+ | "xbox"
30
+ | "battlenet"
31
+ | "riot"
32
+ | "epic"
33
+ | "ea"
34
+ | "activision"
35
+ | "ubisoft"
36
+ | "nintendo"
37
+ | "fightcade"
38
+ | "apple"
39
+ | "googleplay";
40
+
41
+ /** `GameAccount.verification` */
42
+ export type GameAccountVerification =
43
+ | "none"
44
+ | "format";
45
+
25
46
  /** `Game.genre` */
26
47
  export type GameGenre =
27
48
  | "FPS"
@@ -126,6 +147,11 @@ export type PlatformCode =
126
147
  | "nintendo"
127
148
  | "mobile";
128
149
 
150
+ /** `PlayerBan.scope` */
151
+ export type PlayerBanScope =
152
+ | "tournament"
153
+ | "host";
154
+
129
155
  /** `Referral.status` */
130
156
  export type ReferralStatus =
131
157
  | "pending"
@@ -11,6 +11,8 @@ import type {
11
11
  AdminActionTargetType,
12
12
  FaqCategory,
13
13
  FriendshipStatus,
14
+ GameAccountCode,
15
+ GameAccountVerification,
14
16
  GameGenre,
15
17
  GameRequestCustomLobbies,
16
18
  GameRequestGenre,
@@ -23,6 +25,7 @@ import type {
23
25
  MatchStreamPlatform,
24
26
  MatchWinnerSlot,
25
27
  PlatformCode,
28
+ PlayerBanScope,
26
29
  ReferralStatus,
27
30
  TeamCurrentStatus,
28
31
  TeamInviteStatus,
@@ -126,6 +129,8 @@ export interface GameInput {
126
129
  /** Write payload for `game-account`. */
127
130
  export interface GameAccountInput {
128
131
  name?: string;
132
+ code?: GameAccountCode;
133
+ verification?: GameAccountVerification;
129
134
  tournaments?: RelationInput | RelationInput[];
130
135
  user_game_accounts?: RelationInput | RelationInput[];
131
136
  imgThumb?: MediaInput;
@@ -247,6 +252,19 @@ export interface PlatformInput {
247
252
  game_accounts?: RelationInput | RelationInput[];
248
253
  }
249
254
 
255
+ /** Write payload for `player-ban`. */
256
+ export interface PlayerBanInput {
257
+ scope?: PlayerBanScope;
258
+ tournament?: RelationInput;
259
+ host?: RelationInput;
260
+ users_permissions_user?: RelationInput;
261
+ bannedBy?: RelationInput;
262
+ reason?: string;
263
+ expiresAt?: string;
264
+ liftedAt?: string;
265
+ liftedBy?: RelationInput;
266
+ }
267
+
250
268
  /** Write payload for `preset-avatar`. */
251
269
  export interface PresetAvatarInput {
252
270
  image?: MediaInput;
@@ -12,6 +12,8 @@ import type {
12
12
  AdminActionTargetType,
13
13
  FaqCategory,
14
14
  FriendshipStatus,
15
+ GameAccountCode,
16
+ GameAccountVerification,
15
17
  GameGenre,
16
18
  GameRequestCustomLobbies,
17
19
  GameRequestGenre,
@@ -24,6 +26,7 @@ import type {
24
26
  MatchStreamPlatform,
25
27
  MatchWinnerSlot,
26
28
  PlatformCode,
29
+ PlayerBanScope,
27
30
  ReferralStatus,
28
31
  TeamCurrentStatus,
29
32
  TeamInviteStatus,
@@ -127,6 +130,8 @@ export interface Game extends StrapiDocument {
127
130
  /** `game-account` */
128
131
  export interface GameAccount extends StrapiDocument {
129
132
  name?: string | null;
133
+ code?: GameAccountCode | null;
134
+ verification?: GameAccountVerification | null;
130
135
  tournaments?: Tournament[];
131
136
  user_game_accounts?: UserGameAccount[];
132
137
  imgThumb?: StrapiMedia | null;
@@ -248,6 +253,19 @@ export interface Platform extends StrapiDocument {
248
253
  game_accounts?: GameAccount[];
249
254
  }
250
255
 
256
+ /** `player-ban` */
257
+ export interface PlayerBan extends StrapiDocument {
258
+ scope?: PlayerBanScope | null;
259
+ tournament?: Tournament | null;
260
+ host?: User | null;
261
+ users_permissions_user?: User | null;
262
+ bannedBy?: User | null;
263
+ reason?: string | null;
264
+ expiresAt?: string | null;
265
+ liftedAt?: string | null;
266
+ liftedBy?: User | null;
267
+ }
268
+
251
269
  /** `preset-avatar` */
252
270
  export interface PresetAvatar extends StrapiDocument {
253
271
  image?: StrapiMedia | null;
@@ -0,0 +1,250 @@
1
+ /**
2
+ * What a player's handle on each account network looks like.
3
+ *
4
+ * A handle is the one thing opponents actually exchange — the PSN ID, the
5
+ * BattleTag, the Riot ID — so a typo in it is a match that cannot start. Until
6
+ * these rules existed one regex covered every network, and it happily accepted
7
+ * `Name#1234` for PSN (where `#` is not allowed) while rejecting nothing that a
8
+ * real PSN or Xbox rule would.
9
+ *
10
+ * None of this proves ownership. A network whose handles can be linked by
11
+ * OAuth (Steam, Battle.net, Xbox) gets a `linked` tier later; this file is the
12
+ * `format` tier that every network can have today. Which tier applies is a
13
+ * property of the catalogue row (`GameAccount.verification`), set by editors,
14
+ * so a rule that turns out wrong for some network can be switched off without a
15
+ * deploy: set the row to `none` and the generic rule applies.
16
+ *
17
+ * Lives here because the settings page validates as the player types and the
18
+ * backend validates again on write. Two copies of a regex is two chances for a
19
+ * handle to pass one and fail the other.
20
+ *
21
+ * Pure on purpose: no I/O, no profanity list (each app keeps its own), so the
22
+ * rules are unit-tested directly (xpt-strapi
23
+ * `tests/unit/utils/gameAccountHandle.test.ts`).
24
+ */
25
+
26
+ import type {
27
+ GameAccountCode,
28
+ GameAccountVerification,
29
+ } from '../generated/enums';
30
+
31
+ export interface HandleRule {
32
+ /** The network this rule is for; absent on the generic fallback. */
33
+ code?: GameAccountCode;
34
+ /** What the field is called in copy: "PSN online ID", "BattleTag". */
35
+ label: string;
36
+ /** The field's placeholder. */
37
+ placeholder: string;
38
+ /** One sentence under the field saying what a valid value looks like. */
39
+ hint: string;
40
+ /** Applied to the normalized value. */
41
+ pattern: RegExp;
42
+ min: number;
43
+ max: number;
44
+ /**
45
+ * Rewrites the trimmed input before `pattern` is applied: a Steam profile
46
+ * URL down to its id, a bare Nintendo friend code into `SW-xxxx-xxxx-xxxx`.
47
+ * The result is what gets stored.
48
+ */
49
+ normalize?: (value: string) => string;
50
+ }
51
+
52
+ export type HandleCheck =
53
+ | { ok: true; value: string }
54
+ | { ok: false; message: string };
55
+
56
+ /** `error.details.name` values the game-account write endpoints raise. */
57
+ export type GameAccountErrorName = 'HandleInvalid' | 'HandleTaken';
58
+
59
+ /**
60
+ * The rule every network gets when it has no stricter one. Also the backstop
61
+ * the backend applies to every write regardless of network, so every rule
62
+ * below must produce a value that passes this one.
63
+ *
64
+ * Letters are `\p{L}` rather than `A-Z`: BattleTags and Riot IDs allow accented
65
+ * names, and a rule that rejected "Ærøn#1234" would be wrong, not strict.
66
+ */
67
+ export const GENERIC_HANDLE_RULE: HandleRule = {
68
+ label: 'Game ID',
69
+ placeholder: 'Game ID',
70
+ hint: 'Enter your ID exactly as it appears in-game. Letters, numbers, spaces and _ . # - are allowed.',
71
+ pattern: /^[\p{L}\p{N}_ .#-]+$/u,
72
+ min: 3,
73
+ max: 32,
74
+ };
75
+
76
+ const STEAM_PROFILE_URL =
77
+ /^(?:https?:\/\/)?(?:www\.)?steamcommunity\.com\/(?:id|profiles)\/([^/?#\s]+)\/?(?:[?#].*)?$/i;
78
+
79
+ /**
80
+ * Per-network rules. Initial values, and product decisions rather than facts:
81
+ * each is the documented shape as of writing, kept a little looser than the
82
+ * network's own signup validator so a real handle is never refused.
83
+ */
84
+ export const HANDLE_RULES: Partial<Record<GameAccountCode, HandleRule>> = {
85
+ psn: {
86
+ code: 'psn',
87
+ label: 'PSN online ID',
88
+ placeholder: 'PSN online ID',
89
+ hint: '3 to 16 letters, numbers, hyphens or underscores, starting with a letter. No # tag.',
90
+ pattern: /^[A-Za-z][A-Za-z0-9_-]{2,15}$/,
91
+ min: 3,
92
+ max: 16,
93
+ },
94
+ xbox: {
95
+ code: 'xbox',
96
+ label: 'Gamertag',
97
+ placeholder: 'Gamertag or Gamertag#1234',
98
+ hint: 'Letters, numbers and single spaces, with the #1234 suffix if your gamertag has one.',
99
+ pattern: /^[A-Za-z0-9]+(?: [A-Za-z0-9]+)*(?:#\d{3,4})?$/,
100
+ min: 3,
101
+ max: 20,
102
+ },
103
+ battlenet: {
104
+ code: 'battlenet',
105
+ label: 'BattleTag',
106
+ placeholder: 'Name#1234',
107
+ hint: 'Your BattleTag including the number, e.g. Name#1234.',
108
+ pattern: /^\p{L}[\p{L}\p{N}]{2,11}#\d{4,7}$/u,
109
+ min: 8,
110
+ max: 20,
111
+ },
112
+ riot: {
113
+ code: 'riot',
114
+ label: 'Riot ID',
115
+ placeholder: 'GameName#TAG',
116
+ hint: 'Your Riot ID including the tagline, e.g. GameName#TAG.',
117
+ pattern: /^[\p{L}\p{N}](?:[\p{L}\p{N} ]{1,14}[\p{L}\p{N}])?#[\p{L}\p{N}]{3,5}$/u,
118
+ min: 7,
119
+ max: 22,
120
+ },
121
+ epic: {
122
+ code: 'epic',
123
+ label: 'Epic display name',
124
+ placeholder: 'Epic display name',
125
+ hint: '3 to 16 letters, numbers, spaces or - _ . as shown on your Epic account.',
126
+ pattern: /^[\p{L}\p{N}][\p{L}\p{N} ._-]{1,14}[\p{L}\p{N}._-]$/u,
127
+ min: 3,
128
+ max: 16,
129
+ },
130
+ ea: {
131
+ code: 'ea',
132
+ label: 'EA ID',
133
+ placeholder: 'EA ID',
134
+ hint: '4 to 16 letters, numbers, hyphens or underscores.',
135
+ pattern: /^[A-Za-z0-9_-]{4,16}$/,
136
+ min: 4,
137
+ max: 16,
138
+ },
139
+ activision: {
140
+ code: 'activision',
141
+ label: 'Activision ID',
142
+ placeholder: 'Name#1234567',
143
+ hint: 'Your Activision ID including the number, e.g. Name#1234567.',
144
+ pattern: /^[A-Za-z0-9 _-]{2,16}#\d{4,8}$/,
145
+ min: 7,
146
+ max: 25,
147
+ },
148
+ ubisoft: {
149
+ code: 'ubisoft',
150
+ label: 'Ubisoft username',
151
+ placeholder: 'Ubisoft username',
152
+ hint: '3 to 15 letters, numbers or - _ . as shown in Ubisoft Connect.',
153
+ pattern: /^[A-Za-z0-9._-]{3,15}$/,
154
+ min: 3,
155
+ max: 15,
156
+ },
157
+ steam: {
158
+ code: 'steam',
159
+ label: 'Steam ID',
160
+ placeholder: 'Friend code or profile URL',
161
+ hint: 'Your Steam friend code (Friends > Add a Friend), your SteamID64, or your steamcommunity.com profile URL.',
162
+ // A profile URL is the easiest thing for a player to paste; what gets
163
+ // stored is the id or custom name at the end of it.
164
+ normalize: (value) => value.replace(STEAM_PROFILE_URL, '$1'),
165
+ pattern: /^[A-Za-z0-9_-]{3,32}$/,
166
+ min: 3,
167
+ max: 32,
168
+ },
169
+ nintendo: {
170
+ code: 'nintendo',
171
+ label: 'Nintendo friend code',
172
+ placeholder: 'SW-0000-0000-0000',
173
+ hint: 'Your 12-digit Switch friend code, e.g. SW-1234-5678-9012.',
174
+ normalize: (value) => {
175
+ const digits = value.replace(/^sw/i, '').replace(/[\s-]/g, '');
176
+ return /^\d{12}$/.test(digits)
177
+ ? `SW-${digits.slice(0, 4)}-${digits.slice(4, 8)}-${digits.slice(8)}`
178
+ : value;
179
+ },
180
+ pattern: /^SW-\d{4}-\d{4}-\d{4}$/,
181
+ min: 17,
182
+ max: 17,
183
+ },
184
+ // fightcade, apple and googleplay have no documented handle shape and stay
185
+ // on the generic rule.
186
+ };
187
+
188
+ /**
189
+ * The rule a network's handles are checked against: its own when the
190
+ * catalogue row asks for format checks and a rule exists for its code,
191
+ * otherwise the generic one.
192
+ */
193
+ export function handleRuleFor(
194
+ network:
195
+ | {
196
+ code?: GameAccountCode | string | null;
197
+ verification?: GameAccountVerification | string | null;
198
+ }
199
+ | null
200
+ | undefined
201
+ ): HandleRule {
202
+ if (network?.verification !== 'format' || !network.code) {
203
+ return GENERIC_HANDLE_RULE;
204
+ }
205
+ return HANDLE_RULES[network.code as GameAccountCode] ?? GENERIC_HANDLE_RULE;
206
+ }
207
+
208
+ /** True when the code has a rule of its own, so `format` means something. */
209
+ export function hasHandleRule(code: string | null | undefined): boolean {
210
+ return !!code && Object.prototype.hasOwnProperty.call(HANDLE_RULES, code);
211
+ }
212
+
213
+ /**
214
+ * Checks `raw` against `rule` and returns the value to store: trimmed, runs of
215
+ * whitespace collapsed to one space, then the rule's own normalization.
216
+ *
217
+ * The message is the one the player reads, so it says what a valid value looks
218
+ * like rather than which check failed.
219
+ */
220
+ export function checkHandle(
221
+ raw: string | null | undefined,
222
+ rule: HandleRule
223
+ ): HandleCheck {
224
+ const collapsed = (raw ?? '').trim().replace(/\s+/g, ' ');
225
+ if (!collapsed) {
226
+ return { ok: false, message: `Please enter your ${rule.label}` };
227
+ }
228
+
229
+ const value = rule.normalize ? rule.normalize(collapsed) : collapsed;
230
+
231
+ if (value.length < rule.min) {
232
+ return { ok: false, message: `${rule.label} is too short. ${rule.hint}` };
233
+ }
234
+ if (value.length > rule.max) {
235
+ return { ok: false, message: `${rule.label} is too long. ${rule.hint}` };
236
+ }
237
+ if (!rule.pattern.test(value)) {
238
+ return {
239
+ ok: false,
240
+ message: `That doesn't look like a valid ${rule.label}. ${rule.hint}`,
241
+ };
242
+ }
243
+
244
+ return { ok: true, value };
245
+ }
246
+
247
+ /** The key two handles are compared on: same network, same letters, any case. */
248
+ export function normalizeHandleKey(value: string): string {
249
+ return value.trim().replace(/\s+/g, ' ').toLowerCase();
250
+ }
@@ -2,8 +2,10 @@
2
2
  * Hand-written types that are not derived from a Strapi content type.
3
3
  */
4
4
  export * from './country';
5
+ export * from './gameAccountHandle';
5
6
  export * from './lobby';
6
7
  export * from './notification';
8
+ export * from './playerBan';
7
9
  export * from './tournamentEntry';
8
10
  export * from './tournamentInvite';
9
11
  export * from './tournamentJoinRequest';
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Player bans — the contract between `utils/playerBan.ts` in xpt-strapi and
3
+ * the ban dialog, ban list and host-bans page in xpt-client.
4
+ *
5
+ * Two scopes (`PlayerBanScope`, generated): **tournament** keeps a player out
6
+ * of one event and is set by its owner or an admin; **host** keeps them out
7
+ * of every tournament one host owns, now and later, and only that host sets
8
+ * it. The platform level is the account block, not a ban.
9
+ *
10
+ * A ban is active until it is lifted or its `expiresAt` passes; `null` is
11
+ * permanent. Lifted bans are kept, so a list can show history.
12
+ *
13
+ * Routes:
14
+ * - `GET|POST /tournaments/:id/bans`, `DELETE /tournaments/:id/bans/:userId`
15
+ * - `GET|POST /host-bans`, `DELETE /host-bans/:userId`
16
+ * Both GETs take `?includeLifted=true`.
17
+ */
18
+
19
+ import type { PlayerBanScope } from '../generated/enums';
20
+
21
+ export const MAX_BAN_REASON_LENGTH = 500;
22
+
23
+ /** A ban as the list and ban routes return it. */
24
+ export interface PlayerBanSummary {
25
+ documentId: string;
26
+ scope: PlayerBanScope;
27
+ reason: string | null;
28
+ createdAt: string | null;
29
+ /** Null for a permanent ban. */
30
+ expiresAt: string | null;
31
+ liftedAt: string | null;
32
+ /** Not lifted and not expired. */
33
+ active: boolean;
34
+ users_permissions_user: {
35
+ id: number;
36
+ documentId: string | null;
37
+ username: string | null;
38
+ image: unknown;
39
+ } | null;
40
+ bannedBy: { id: number; username: string | null } | null;
41
+ liftedBy: { id: number; username: string | null } | null;
42
+ /** Set on tournament-scope bans. */
43
+ tournament: { documentId: string; title: string | null } | null;
44
+ /**
45
+ * Whether the viewer may lift it. On a tournament's list a host ban is
46
+ * liftable only by the owner — an admin sees it but cannot lift it.
47
+ */
48
+ canLift: boolean;
49
+ }
50
+
51
+ /** `POST /tournaments/:id/bans` and `POST /host-bans`. */
52
+ export interface BanPlayerBody {
53
+ userId: number;
54
+ reason?: string | null;
55
+ /** ISO date in the future; omit or null for permanent. */
56
+ expiresAt?: string | null;
57
+ /**
58
+ * Also withdraw the entry the player registered (with its refund), while
59
+ * the tournament still takes entries. Host bans apply it across all of the
60
+ * host's open tournaments.
61
+ */
62
+ removeEntry?: boolean;
63
+ }
64
+
65
+ /** Why `removeEntry` left an entry where it was. */
66
+ export type BanRemovalSkip =
67
+ /** No entry in this tournament. Never returned for host bans. */
68
+ | 'notRegistered'
69
+ /** On a team someone else registered: the team's leader must swap them out. */
70
+ | 'notRegistrant'
71
+ /** Past check-in; the bracket is seeded. */
72
+ | 'entryLocked'
73
+ /** The withdrawal refused; `message` says why. */
74
+ | 'failed';
75
+
76
+ export interface BanRemoval {
77
+ tournamentDocumentId: string;
78
+ tournamentTitle: string | null;
79
+ removed: boolean;
80
+ /** Coins returned to whoever paid. */
81
+ refunded: number;
82
+ skipped?: BanRemovalSkip;
83
+ message?: string;
84
+ }
85
+
86
+ /** The `data` of a successful ban. */
87
+ export interface BanResult {
88
+ ban: PlayerBanSummary;
89
+ /** Pending join requests declined and refunded because of the ban. */
90
+ declinedRequests: number;
91
+ /** Empty unless `removeEntry` was asked for. */
92
+ removals: BanRemoval[];
93
+ }
94
+
95
+ /** The durations the ban dialog offers. */
96
+ export const BAN_DURATIONS = [
97
+ { key: '1d', label: '24 hours', days: 1 },
98
+ { key: '7d', label: '7 days', days: 7 },
99
+ { key: '30d', label: '30 days', days: 30 },
100
+ { key: 'permanent', label: 'Permanent', days: null },
101
+ ] as const;
102
+
103
+ export type BanDurationKey = (typeof BAN_DURATIONS)[number]['key'];
104
+
105
+ /** `expiresAt` for a duration, or null for permanent. */
106
+ export function banExpiryFor(
107
+ key: BanDurationKey,
108
+ now: Date = new Date()
109
+ ): string | null {
110
+ const days = BAN_DURATIONS.find((d) => d.key === key)?.days ?? null;
111
+ return days == null
112
+ ? null
113
+ : new Date(now.getTime() + days * 86_400_000).toISOString();
114
+ }
115
+
116
+ export function isBanActive(
117
+ ban: { liftedAt?: string | null; expiresAt?: string | null },
118
+ now: Date = new Date()
119
+ ): boolean {
120
+ if (ban.liftedAt) return false;
121
+ if (!ban.expiresAt) return true;
122
+ return new Date(ban.expiresAt).getTime() > now.getTime();
123
+ }
@@ -51,7 +51,9 @@ export type TournamentStaffAction =
51
51
  /** Record scores, start matches, edit match details. */
52
52
  | 'manageMatches'
53
53
  /** Watch any lobby and post as staff in its chat. */
54
- | 'enterLobby';
54
+ | 'enterLobby'
55
+ /** Ban players from this tournament and lift those bans. */
56
+ | 'manageBans';
55
57
 
56
58
  const OWNER_ONLY = ['owner'] as const;
57
59
  const ADMIN_UP = ['owner', 'admin'] as const;
@@ -72,6 +74,7 @@ export const TOURNAMENT_STAFF_CAPABILITIES: Record<
72
74
  reviewJoinRequests: ADMIN_UP,
73
75
  resolveDispute: ADMIN_UP,
74
76
  seedBracket: ADMIN_UP,
77
+ manageBans: ADMIN_UP,
75
78
  manageMatches: ALL_STAFF,
76
79
  enterLobby: ALL_STAFF,
77
80
  };