feat(client): account usernames + verified badge on player-facing lists (#4653)

## Summary

Client side of openfrontio/infra#436 — every player-facing GET now
returns the player's **account username** next to their `publicId`, and
this PR renders it everywhere, with a blue verified check for
premium/indefinite bare-name holders.

### Schemas (all additive, `.nullable().optional()` — no deploy-order
constraint)
- `FriendEntry`, `PlayerProfile`: `username`
- `ClanMember`, `ClanJoinRequest`: `username`; `ClanBan`: `username` +
`bannedByUsername`
- `RankedLeaderboardEntry`: `accountUsername` (the existing session
`username` field is untouched)

### `<player-name>` element
One component owns player-identity rendering on account surfaces:
- displays `username ?? publicId`
- click-to-copy copies the **username when set**, the publicId
otherwise; `copyText` overrides (profile modal copies the share URL)
- `onNameClick` replaces copying with an action (clan member rows +
leaderboard rows open the player's profile); `nameClass` overrides the
chip styling so leaderboard names keep their original bold look
- appends the verified check (same mark as the username-row toggle) when
`isVerifiedUsername(username)`

Used by: friends list + requests, all clan member/request/ban rows (bans
badge both the banned player and the issuing officer), ranked
leaderboard rows + sticky self row (showing `accountUsername ??
public_id` — the session name is deliberately dropped ahead of its API
removal), profile modal header.

### Verified inference
No API flag needed: account-username bases can never contain dots, so a
dotless display name **is** the bare-name claim (premium/indefinite).
`TEMPORARY####` server renames are excluded, matching the play-toggle
eligibility rule. The leaderboard derives the badge from
`accountUsername` only — session names are free-form and would
false-positive.

Also: clan member/request/ban search filters now match usernames, not
just publicIds, and sending a friend request refetches the request lists
instead of inserting the raw input (which may be a username, not a
publicId).

## Test plan
- [x] `npm test` — full suite green (2087 + 176 server)
- [x] New schema tests: each field's set / null / absent states;
`accountUsername` coexists with session `username`
- [x] `isVerifiedUsername` unit tests (bare / suffixed / null /
TEMPORARY)
- [x] tsc, ESLint, Prettier clean

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Evan
2026-07-20 14:15:10 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent ad8d7e995a
commit 826d8b1bcf
16 changed files with 433 additions and 102 deletions
+34 -9
View File
@@ -1,11 +1,12 @@
import { html } from "lit";
import { html, nothing } from "lit";
import { customElement, state } from "lit/decorators.js";
import type { PlayerStatsTree } from "../core/ApiSchemas";
import { isVerifiedUsername, type PlayerStatsTree } from "../core/ApiSchemas";
import { fetchPublicPlayerProfile } from "./Api";
import "./components/baseComponents/stats/PlayerStatsTree";
import { BaseModal } from "./components/BaseModal";
import "./components/CopyButton";
import "./components/PlayerName";
import { modalHeader } from "./components/ui/ModalHeader";
import { verifiedBadge } from "./components/ui/VerifiedBadge";
import { translateText } from "./Utils";
/** Build a shareable profile URL for a publicId. */
@@ -18,9 +19,10 @@ export class PlayerProfileModal extends BaseModal {
protected routerName = "profile";
@state() private publicId: string | null = null;
@state() private username: string | null = null;
@state() private statsTree: PlayerStatsTree | null = null;
@state() private loading = false;
private openedFrom: "clan" | null = null;
private openedFrom: "clan" | "leaderboard" | null = null;
protected modalConfig() {
return { maxWidth: "960px" };
@@ -29,17 +31,28 @@ export class PlayerProfileModal extends BaseModal {
protected renderHeaderSlot() {
return modalHeader({
title: translateText("player_profile.title"),
// The account username takes over the title when set — not uppercased
// like the default title, since name casing is meaningful — and the
// right chip then always shows the publicId.
titleContent: this.username
? html`<span
class="text-white text-xl lg:text-2xl font-bold tracking-wide break-words hyphens-auto min-w-0 inline-flex items-center gap-2"
>
${this.username}
${isVerifiedUsername(this.username)
? verifiedBadge("w-5 h-5")
: nothing}
</span>`
: undefined,
onBack: () => this.back(),
ariaLabel: translateText("common.back"),
rightContent: this.publicId
? html`
<copy-button
compact
<player-name
class="shrink-0"
.publicId=${this.publicId}
.copyText=${playerProfileUrl(this.publicId)}
.displayText=${this.publicId}
.showVisibilityToggle=${false}
></copy-button>
></player-name>
`
: undefined,
});
@@ -82,6 +95,7 @@ export class PlayerProfileModal extends BaseModal {
? args.publicID
: null;
this.publicId = publicId;
this.username = null;
this.statsTree = null;
this.loading = publicId !== null;
if (publicId !== null) {
@@ -95,10 +109,12 @@ export class PlayerProfileModal extends BaseModal {
if (this.publicId !== publicId) return;
this.loading = false;
this.statsTree = profile === false ? null : profile.stats;
this.username = profile === false ? null : (profile.username ?? null);
}
protected onClose(): void {
this.publicId = null;
this.username = null;
this.statsTree = null;
this.loading = false;
this.openedFrom = null;
@@ -109,6 +125,11 @@ export class PlayerProfileModal extends BaseModal {
this.open({ publicID: publicId });
}
public openFromLeaderboard(publicId: string): void {
this.openedFrom = "leaderboard";
this.open({ publicID: publicId });
}
private back(): void {
const openedFrom = this.openedFrom;
this.close();
@@ -116,6 +137,10 @@ export class PlayerProfileModal extends BaseModal {
document
.querySelector<HTMLElement & { returnToMembers(): void }>("clan-modal")
?.returnToMembers();
} else if (openedFrom === "leaderboard") {
document
.querySelector<HTMLElement & { open(): void }>("leaderboard-modal")
?.open();
}
}
}
+20 -20
View File
@@ -10,7 +10,7 @@ import {
sendFriendRequest,
} from "../FriendsApi";
import { showToast, translateText } from "../Utils";
import "./CopyButton";
import "./PlayerName";
const PAGE_LIMIT = 20;
@@ -116,10 +116,16 @@ export class FriendsList extends LitElement {
await this.loadAll();
} else {
showToast(translateText("friends.request_sent"), "green");
this.outgoing = [
...this.outgoing,
{ publicId: target, createdAt: new Date().toISOString() },
];
// Refetch rather than inserting the raw input: the target may have
// been typed as a username, and the server-returned entry carries
// the real publicId + account username (so the verified check shows).
// If the refetch fails the list is just stale until the next load —
// never insert `target`, which may not be a publicId.
const requests = await fetchFriendRequests();
if (requests) {
this.incoming = requests.incoming;
this.outgoing = requests.outgoing;
}
}
} finally {
this.actionPending = false;
@@ -261,7 +267,7 @@ export class FriendsList extends LitElement {
}}
class="flex-1 px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder-white/30 focus:outline-none focus:ring-2 focus:ring-malibu-blue/50 focus:border-malibu-blue/50 transition-all font-mono text-sm"
placeholder=${translateText("friends.public_id_placeholder")}
maxlength="22"
maxlength="25"
?disabled=${this.actionPending}
/>
<button
@@ -325,13 +331,10 @@ export class FriendsList extends LitElement {
class="flex items-center gap-3 bg-white/5 rounded-lg border border-white/10 p-3"
>
<div class="flex-1 min-w-0">
<copy-button
compact
.copyText=${entry.publicId}
.displayText=${entry.publicId}
.showVisibilityToggle=${false}
.showCopyIcon=${false}
></copy-button>
<player-name
.username=${entry.username}
.publicId=${entry.publicId}
></player-name>
<div class="text-white/30 text-[10px] mt-0.5">
${this.formatDate(entry.createdAt)}
</div>
@@ -400,13 +403,10 @@ export class FriendsList extends LitElement {
class="flex items-center gap-3 bg-white/5 rounded-lg border border-white/10 p-3"
>
<div class="flex-1 min-w-0">
<copy-button
compact
.copyText=${f.publicId}
.displayText=${f.publicId}
.showVisibilityToggle=${false}
.showCopyIcon=${false}
></copy-button>
<player-name
.username=${f.username}
.publicId=${f.publicId}
></player-name>
<div class="text-white/30 text-[10px] mt-0.5">
${translateText("friends.friends_since", {
date: this.formatDate(f.createdAt),
+65
View File
@@ -0,0 +1,65 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { isVerifiedUsername } from "../../core/ApiSchemas";
import { translateText } from "../Utils";
import "./CopyButton";
import { verifiedBadge } from "./ui/VerifiedBadge";
/**
* Standard rendering of a player identity on account surfaces (friends, clan
* lists, leaderboards, profiles): the account username when set — with the
* verified check when it's a bare-name claim (isVerifiedUsername) — falling
* back to the publicId. Clicking copies the account username when set, the
* publicId otherwise. `copyText` overrides the copy payload entirely (e.g. a
* share URL); `onNameClick` replaces copying with an action (e.g. opening
* the player's profile), styled as the same chip unless `nameClass`
* overrides it.
*/
@customElement("player-name")
export class PlayerName extends LitElement {
@property({ attribute: false }) username: string | null | undefined = null;
@property({ type: String }) publicId = "";
// Copy payload override (e.g. a share URL).
@property({ type: String }) copyText = "";
// When set, clicking the name runs this instead of copying.
@property({ attribute: false }) onNameClick: (() => void) | null = null;
// Styling override for the clickable name (e.g. leaderboard rows keep
// their original bold look instead of the publicId-chip look).
@property({ type: String }) nameClass = "";
createRenderRoot() {
return this;
}
render() {
const displayName = this.username ?? this.publicId;
const copyText =
this.copyText !== "" ? this.copyText : (this.username ?? this.publicId);
// inline-flex so the element sits on one line with inline siblings
// (dates, role chips) while keeping the badge glued to the name.
return html`
<span class="inline-flex items-center gap-1.5 min-w-0 max-w-full">
${this.onNameClick
? html`<button
type="button"
class=${this.nameClass !== ""
? this.nameClass
: "text-xs text-white/60 font-mono bg-white/5 px-2 py-0.5 rounded border border-white/5 hover:bg-white/10 hover:text-white transition-colors"}
title=${translateText("player_profile.view")}
aria-label=${translateText("player_profile.view")}
@click=${() => this.onNameClick?.()}
>
${displayName}
</button>`
: html`<copy-button
compact
.copyText=${copyText}
.displayText=${displayName}
.showVisibilityToggle=${false}
.showCopyIcon=${false}
></copy-button>`}
${isVerifiedUsername(this.username) ? verifiedBadge() : nothing}
</span>
`;
}
}
+16 -18
View File
@@ -2,7 +2,7 @@ import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { type ClanBan, fetchClanBans, unbanClanMember } from "../../ClanApi";
import { translateText } from "../../Utils";
import "../CopyButton";
import "../PlayerName";
import {
formatClanDate,
renderLoadingSpinner,
@@ -94,9 +94,13 @@ export class ClanBansView extends LitElement {
const totalPages = Math.ceil(this.bansTotal / this.bansLimit);
const filtered = this.memberSearch
? this.bans.filter((b) =>
b.publicId.toLowerCase().includes(this.memberSearch.toLowerCase()),
)
? this.bans.filter((b) => {
const q = this.memberSearch.toLowerCase();
return (
b.publicId.toLowerCase().includes(q) ||
(b.username?.toLowerCase().includes(q) ?? false)
);
})
: this.bans;
return html`
@@ -146,23 +150,17 @@ export class ClanBansView extends LitElement {
/>
</svg>
</div>
<copy-button
compact
.copyText=${ban.publicId}
.displayText=${ban.publicId}
.showVisibilityToggle=${false}
.showCopyIcon=${false}
></copy-button>
<player-name
.username=${ban.username}
.publicId=${ban.publicId}
></player-name>
<span class="text-white/30 text-xs shrink-0"
>${translateText("clan_modal.banned_by_label")}</span
>
<copy-button
compact
.copyText=${ban.bannedBy}
.displayText=${ban.bannedBy}
.showVisibilityToggle=${false}
.showCopyIcon=${false}
></copy-button>
<player-name
.username=${ban.bannedByUsername}
.publicId=${ban.bannedBy}
></player-name>
<span class="text-white/30 text-xs shrink-0"
>${formatClanDate(ban.createdAt)}</span
>
+5 -8
View File
@@ -16,7 +16,7 @@ import {
} from "../../ClanApi";
import { translateText } from "../../Utils";
import "../ConfirmDialog";
import "../CopyButton";
import "../PlayerName";
import {
type ClanRole,
defaultOrderForSort,
@@ -554,13 +554,10 @@ export class ClanManageView extends LitElement {
>
${renderRoleIcon(member.role)}
</div>
<copy-button
compact
.copyText=${member.publicId}
.displayText=${member.publicId}
.showVisibilityToggle=${false}
.showCopyIcon=${false}
></copy-button>
<player-name
.username=${member.username}
.publicId=${member.publicId}
></player-name>
<span class="text-white/30 text-[10px] whitespace-nowrap">
${translateText("clan_modal.joined_date", {
date: formatClanDate(member.joinedAt),
@@ -8,7 +8,7 @@ import {
fetchClanRequests,
} from "../../ClanApi";
import { translateText } from "../../Utils";
import "../CopyButton";
import "../PlayerName";
import {
filterRequestsBySearch,
formatClanDate,
@@ -153,13 +153,10 @@ export class ClanRequestsView extends LitElement {
class="flex items-center gap-3 bg-white/5 rounded-xl border border-white/10 p-4"
>
<div class="flex-1 min-w-0">
<copy-button
compact
.copyText=${req.publicId}
.displayText=${req.publicId}
.showVisibilityToggle=${false}
.showCopyIcon=${false}
></copy-button>
<player-name
.username=${req.username}
.publicId=${req.publicId}
></player-name>
<span class="text-white/30 text-[10px]">
${translateText("clan_modal.requested_on", {
tag: this.selectedClan?.tag ?? this.clanTag,
+17 -19
View File
@@ -7,6 +7,7 @@ import type {
ClanMemberStats,
} from "../../ClanApi";
import { showToast, translateText } from "../../Utils";
import "../PlayerName";
import "./ClanStatsBreakdown";
export { renderLoadingSpinner } from "../BaseModal";
export { showToast };
@@ -400,13 +401,14 @@ export function renderMemberRow(
<div class="flex-1 min-w-0 flex flex-col">
<div class="flex items-center justify-between gap-2">
<div class="min-w-0">
<copy-button
compact
.copyText=${member.publicId}
.displayText=${member.publicId}
.showVisibilityToggle=${false}
.showCopyIcon=${false}
></copy-button>
<player-name
.username=${member.username}
.publicId=${member.publicId}
.nameClass=${"font-bold text-blue-300 truncate text-base hover:underline"}
.onNameClick=${onViewProfile
? () => onViewProfile(member.publicId)
: null}
></player-name>
</div>
<div class="flex items-center gap-2 shrink-0">
<span
@@ -415,16 +417,6 @@ export function renderMemberRow(
date: formatClanDate(member.joinedAt),
})}</span
>
${onViewProfile
? html`<button
class="shrink-0 px-1.5 py-0.5 rounded-lg bg-white/5 hover:bg-white/10 border border-white/10 transition-colors"
title=${translateText("player_profile.view")}
aria-label=${translateText("player_profile.view")}
@click=${() => onViewProfile(member.publicId)}
>
📊
</button>`
: ""}
</div>
</div>
</div>
@@ -442,7 +434,9 @@ export function filterMembersBySearch(
const q = search.toLowerCase();
return members.filter(
(m) =>
m.publicId.toLowerCase().includes(q) || m.role.toLowerCase().includes(q),
m.publicId.toLowerCase().includes(q) ||
m.role.toLowerCase().includes(q) ||
(m.username?.toLowerCase().includes(q) ?? false),
);
}
@@ -452,5 +446,9 @@ export function filterRequestsBySearch(
): ClanJoinRequest[] {
if (!search) return requests;
const q = search.toLowerCase();
return requests.filter((r) => r.publicId.toLowerCase().includes(q));
return requests.filter(
(r) =>
r.publicId.toLowerCase().includes(q) ||
(r.username?.toLowerCase().includes(q) ?? false),
);
}
@@ -9,7 +9,7 @@ import {
} from "../../ClanApi";
import { translateText } from "../../Utils";
import "../ConfirmDialog";
import "../CopyButton";
import "../PlayerName";
import {
filterMembersBySearch,
renderLoadingSpinner,
@@ -169,13 +169,10 @@ export class ClanTransferView extends LitElement {
${renderRoleIcon(m.role)}
</div>
<div class="flex-1 min-w-0">
<copy-button
compact
.copyText=${m.publicId}
.displayText=${m.publicId}
.showVisibilityToggle=${false}
.showCopyIcon=${false}
></copy-button>
<player-name
.username=${m.username}
.publicId=${m.publicId}
></player-name>
</div>
<span
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full shrink-0
@@ -4,6 +4,7 @@ import { PlayerLeaderboardEntry } from "../../../core/ApiSchemas";
import { RankedType } from "../../../core/game/Game";
import { fetchPlayerLeaderboard, getUserMe } from "../../Api";
import { translateText } from "../../Utils";
import "../PlayerName";
@customElement("leaderboard-player-list")
export class LeaderboardPlayerList extends LitElement {
@@ -73,7 +74,7 @@ export class LeaderboardPlayerList extends LitElement {
].map((entry) => ({
rank: entry.rank,
playerId: entry.public_id,
username: entry.username,
accountUsername: entry.accountUsername ?? null,
clanTag: entry.clanTag ?? undefined,
elo: entry.elo,
games: entry.total,
@@ -140,6 +141,16 @@ export class LeaderboardPlayerList extends LitElement {
this.schedulePlayerFillCheck();
}
// Same handoff as the clan views: the profile modal's back button reopens
// the leaderboard (its loaded pages survive the close).
private openProfile(publicId: string) {
document
.querySelector<
HTMLElement & { openFromLeaderboard(publicId: string): void }
>("player-profile-modal")
?.openFromLeaderboard(publicId);
}
// TODO: consider IntersectionObserver for better visibility detection?
private isVisible() {
return this.isConnected && this.getClientRects().length > 0;
@@ -248,9 +259,12 @@ export class LeaderboardPlayerList extends LitElement {
${player.clanTag}
</div>`
: ""}
<span class="font-bold text-blue-300 truncate text-base"
>${player.username}</span
>
<player-name
.username=${player.accountUsername}
.publicId=${player.playerId}
.nameClass=${"font-bold text-blue-300 truncate text-base hover:underline"}
.onNameClick=${() => this.openProfile(player.playerId)}
></player-name>
</div>
</td>
<td class="py-3 px-4 text-right">
@@ -440,9 +454,13 @@ export class LeaderboardPlayerList extends LitElement {
${this.currentUserEntry.clanTag}
</div>`
: ""}
<span class="font-bold text-white text-base"
>${this.currentUserEntry.username}</span
>
<player-name
.username=${this.currentUserEntry.accountUsername}
.publicId=${this.currentUserEntry.playerId}
.nameClass=${"font-bold text-white text-base hover:underline"}
.onNameClick=${() =>
this.openProfile(this.currentUserEntry!.playerId)}
></player-name>
</div>
</div>
<div class="flex flex-col items-end w-20">
+25
View File
@@ -0,0 +1,25 @@
import { html, TemplateResult } from "lit";
import { translateText } from "../../Utils";
// The same blue check-circle as the UsernameInput verified toggle, sized for
// inline placement next to a player name in lists. Callers gate on
// isVerifiedUsername(accountUsername) — never on session/free-form names.
export function verifiedBadge(sizeClass = "w-4 h-4"): TemplateResult {
return html`<svg
viewBox="0 0 24 24"
class="${sizeClass} text-blue-400 shrink-0"
role="img"
aria-label=${translateText("username.verified_player")}
>
<title>${translateText("username.verified_player")}</title>
<circle cx="12" cy="12" r="10" fill="currentColor"></circle>
<path
d="M7.5 12.5l3 3 6-6.5"
stroke="white"
stroke-width="2.2"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>`;
}
+32 -1
View File
@@ -128,6 +128,23 @@ export function isTemporaryUsername(base: string | null | undefined): boolean {
return typeof base === "string" && /^TEMPORARY\d{4}$/.test(base);
}
// Whether a pre-rendered account username belongs to a verified player
// (premium/indefinite bare-name claim holder). The server renders everyone
// else as "base.suffix" and bases can never contain dots
// (AccountUsernameSchema), so a dotless name IS the bare display — no
// dedicated flag needed on list endpoints. TEMPORARY#### server renames are
// bare too but aren't a chosen name, so they don't get the badge (matches
// the verified-name play toggle's eligibility rule).
export function isVerifiedUsername(
username: string | null | undefined,
): boolean {
return (
typeof username === "string" &&
!username.includes(".") &&
!isTemporaryUsername(username)
);
}
export const UserMeResponseSchema = z.object({
user: z.object({
discord: DiscordUserSchema.optional(),
@@ -263,6 +280,11 @@ export type PlayerStatsTree = z.infer<typeof PlayerStatsTreeSchema>;
export const PlayerProfileSchema = z.object({
createdAt: z.iso.datetime(),
user: DiscordUserSchema.optional(),
// Account username, pre-rendered by the server (bare base or "base.suffix").
// null = the player never set one. Render `username ?? publicId`; never
// parse it — compare players by publicId only. Optional so responses from
// an API without the field still parse.
username: z.string().nullable().optional(),
stats: PlayerStatsTreeSchema,
});
export type PlayerProfile = z.infer<typeof PlayerProfileSchema>;
@@ -321,7 +343,9 @@ export type PublicPlayerGamesResponse = z.infer<
export const PlayerLeaderboardEntrySchema = z.object({
rank: z.number(),
playerId: z.string(),
username: LeaderboardUsernameSchema,
// Account username (null = never set). The leaderboard displays this or
// the playerId — the per-session name is deliberately ignored.
accountUsername: z.string().nullable().optional(),
clanTag: RequiredClanTagSchema.nullable().optional(),
flag: z.string().optional(),
elo: z.number(),
@@ -351,6 +375,10 @@ export const RankedLeaderboardEntrySchema = z.object({
public_id: z.string(),
user: DiscordUserSchema.nullable().optional(),
username: LeaderboardUsernameSchema,
// Account username (null = never set), unlike `username` which is the name
// from the player's most recent ranked session. The client displays
// `accountUsername ?? public_id` and ignores the session name.
accountUsername: z.string().nullable().optional(),
clanTag: RequiredClanTagSchema.nullable().optional(),
});
export type RankedLeaderboardEntry = z.infer<
@@ -366,6 +394,9 @@ export type RankedLeaderboardResponse = z.infer<
export const FriendEntrySchema = z.object({
publicId: z.string(),
// Account username (null = never set), always the other party's. Render
// `username ?? publicId`; identify players by publicId only.
username: z.string().nullable().optional(),
createdAt: z.iso.datetime(),
});
export type FriendEntry = z.infer<typeof FriendEntrySchema>;
+10
View File
@@ -128,6 +128,10 @@ export const ClanMemberSchema = z.object({
role: z.enum(["leader", "officer", "member"]),
joinedAt: z.iso.datetime(),
publicId: z.string(),
// Account username, pre-rendered by the server (null = never set). Render
// `username ?? publicId`; identify players by publicId only. Optional so
// responses from an API without the field still parse.
username: z.string().nullable().optional(),
stats: ClanMemberStatsSchema.optional(),
});
export type ClanMember = z.infer<typeof ClanMemberSchema>;
@@ -143,6 +147,8 @@ export type ClanMembersResponse = z.infer<typeof ClanMembersResponseSchema>;
export const ClanJoinRequestSchema = z.object({
publicId: z.string(),
// Requester's account username (null = never set).
username: z.string().nullable().optional(),
createdAt: z.iso.datetime(),
});
export type ClanJoinRequest = z.infer<typeof ClanJoinRequestSchema>;
@@ -157,7 +163,11 @@ export type ClanRequestsResponse = z.infer<typeof ClanRequestsResponseSchema>;
export const ClanBanSchema = z.object({
publicId: z.string(),
// Banned player's account username (null = never set).
username: z.string().nullable().optional(),
bannedBy: z.string(),
// Account username of the officer who issued the ban.
bannedByUsername: z.string().nullable().optional(),
reason: z.string().max(200).nullable(),
createdAt: z.iso.datetime(),
});