Add opt-in Discord pings to standings notifications (#429)

* Add opt-in Discord ping to standings notifications

Users who have linked their Discord account can enable a ping preference
in Settings > Notifications. When enabled, their bracket username is replaced
with a Discord @mention (<@userId>) in league standings update messages,
sending them a push notification and showing their Discord display name.

Adds discordPingEnabled column to users, a findDiscordIdsByUserIds model
helper, allowed_mentions support to the Discord webhook payload, and a
NotificationsSection settings component.

https://claude.ai/code/session_01NUv93WRrufHhpZSMyY4bjs

* Remove Display Name field from user profile settings

Username is the only user-facing name field. The display_name column
remains in the database since BetterAuth writes the OAuth provider name
there, and it serves as a programmatic fallback via getUserDisplayName.

https://claude.ai/code/session_01NUv93WRrufHhpZSMyY4bjs

* Address code review feedback on Discord ping feature

- Fix broken Account settings link in NotificationsSection: replace the
  non-functional ?section=account href with an onNavigateToAccount callback
  that drives the parent's useState-based section switcher
- Replace hand-rolled toggle button with the project's Switch component
  (Radix UI) for consistent sizing, accessibility, and dark-mode support
- Guard update-discord-ping action: return an error if the user attempts
  to enable pings without a Discord account linked
- Surface action error in NotificationsSection UI
- Cap allowed_mentions.users at 100 to avoid Discord rejecting the payload
  in large leagues
- Remove the showWinner alias in scoring-calculator; inline winnerScoreChanged
- Add comment to league settings test notification explaining why discordUserId
  is omitted
- Clarify database schema comment: displayName is write-only from BetterAuth

https://claude.ai/code/session_01NUv93WRrufHhpZSMyY4bjs

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-05-15 10:06:54 -07:00 committed by GitHub
parent d4a9fc8aaf
commit 469cc82e15
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 6431 additions and 32 deletions

View file

@ -0,0 +1,81 @@
import { useFetcher } from "react-router";
import { Switch } from "~/components/ui/switch";
import { Label } from "~/components/ui/label";
type Props = {
discordPingEnabled: boolean;
hasDiscordLinked: boolean;
success?: boolean;
error?: string;
onNavigateToAccount: () => void;
};
export function NotificationsSection({ discordPingEnabled, hasDiscordLinked, success, error, onNavigateToAccount }: Props) {
const fetcher = useFetcher();
const optimisticEnabled =
fetcher.state !== "idle"
? fetcher.formData?.get("discordPingEnabled") === "true"
: discordPingEnabled;
const handleToggle = (checked: boolean) => {
fetcher.submit(
{ intent: "update-discord-ping", discordPingEnabled: String(checked) },
{ method: "post" }
);
};
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold">Notifications</h2>
<p className="text-sm text-muted-foreground">
Manage how you receive league notifications.
</p>
</div>
<div className="rounded-lg border p-4 space-y-4">
<h3 className="text-sm font-medium">Discord Pings</h3>
{!hasDiscordLinked ? (
<p className="text-sm text-muted-foreground">
Link your Discord account in{" "}
<button
type="button"
onClick={onNavigateToAccount}
className="text-primary underline-offset-4 hover:underline"
>
Account settings
</button>{" "}
to enable Discord pings for league notifications.
</p>
) : (
<>
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<Label htmlFor="discord-ping-switch" className="text-sm font-normal">
Ping me in standings updates
</Label>
<p className="text-xs text-muted-foreground">
When enabled, you'll be @mentioned in league Discord notifications and your
Discord display name will appear in place of your brackt username.
</p>
</div>
<Switch
id="discord-ping-switch"
checked={optimisticEnabled}
onCheckedChange={handleToggle}
disabled={fetcher.state !== "idle"}
/>
</div>
{success && (
<p className="text-sm text-green-600">Notification preference saved.</p>
)}
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</>
)}
</div>
</div>
);
}

View file

@ -39,7 +39,7 @@ export function ProfileSection({ user, isInActiveDraft, success, error }: Props)
<div>
<h2 className="text-lg font-semibold">Profile</h2>
<p className="text-sm text-muted-foreground">
Your display name, username, avatar, and timezone.
Your username, avatar, and timezone.
</p>
</div>
@ -58,14 +58,6 @@ export function ProfileSection({ user, isInActiveDraft, success, error }: Props)
</div>
<Form method="post" className="space-y-4">
<div className="space-y-2">
<Label htmlFor="displayName">Display Name</Label>
<Input
id="displayName"
name="displayName"
defaultValue={user.displayName ?? ""}
/>
</div>
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input

View file

@ -1,4 +1,4 @@
import { eq } from "drizzle-orm";
import { eq, inArray, and } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
@ -14,3 +14,13 @@ export async function findLinkedAccountsByUserId(userId: string): Promise<Linked
.from(schema.accounts)
.where(eq(schema.accounts.userId, userId));
}
export async function findDiscordIdsByUserIds(userIds: string[]): Promise<Map<string, string>> {
if (userIds.length === 0) return new Map();
const db = database();
const rows = await db
.select({ userId: schema.accounts.userId, accountId: schema.accounts.accountId })
.from(schema.accounts)
.where(and(inArray(schema.accounts.userId, userIds), eq(schema.accounts.providerId, "discord")));
return new Map(rows.map((r) => [r.userId, r.accountId]));
}

View file

@ -8,6 +8,7 @@ import { sendStandingsUpdateNotification, type ScoredMatch } from "~/services/di
import { BRACKET_TEMPLATES } from "~/lib/bracket-templates";
import { doesLoserAdvance } from "~/models/playoff-match";
import { getUserDisplayName } from "~/models/user";
import { findDiscordIdsByUserIds } from "~/models/account";
import { createDailySnapshot } from "~/models/standings";
import { recordMatchScoreEvents } from "~/models/team-score-events";
import { logger } from "~/lib/logger";
@ -1455,6 +1456,12 @@ export async function recalculateAffectedLeagues(
.filter((entry): entry is [string, string] => entry[1] !== null)
);
// Build userId → Discord ID map for opted-in users
const optedInUserIds = teamOwnerUsers
.filter((u) => u.discordPingEnabled)
.map((u) => u.id);
const discordIdByUserId = await findDiscordIdsByUserIds(optedInUserIds);
// Build teamId → ownerId map from standings data
const ownerIdByTeamId = new Map(
afterStandings.map((s) => [s.teamId, s.team.ownerId])
@ -1485,13 +1492,11 @@ export async function recalculateAffectedLeagues(
draftPicks.map((p) => [p.participantId, p.teamId])
);
const lookupUsername = (participantId: string | null | undefined) => {
const lookupOwner = (participantId: string | null | undefined) => {
if (!participantId) return undefined;
const teamId = teamIdByParticipantId.get(participantId);
if (!teamId) return undefined;
const ownerId = ownerIdByTeamId.get(teamId);
if (!ownerId) return undefined;
return usernameByUserId.get(ownerId);
return ownerIdByTeamId.get(teamId);
};
scoredMatches = relevant
@ -1500,13 +1505,16 @@ export async function recalculateAffectedLeagues(
const winnerTeamId = m.winnerId ? teamIdByParticipantId.get(m.winnerId) : undefined;
const loserTeamId = m.loserId ? teamIdByParticipantId.get(m.loserId) : undefined;
const winnerScoreChanged = winnerTeamId ? changedTeamIds.has(winnerTeamId) : false;
const winnerOwnerId = lookupOwner(m.winnerId);
const loserOwnerId = lookupOwner(m.loserId);
const showLoser = isLoserNotifiable(m.loserId, loserTeamId, changedTeamIds, finalizedLoserIds);
return {
winnerName: m.winnerName ?? "",
loserName: m.loserName ?? "",
winnerUsername: winnerScoreChanged ? lookupUsername(m.winnerId) : undefined,
loserUsername: isLoserNotifiable(m.loserId, loserTeamId, changedTeamIds, finalizedLoserIds)
? lookupUsername(m.loserId)
: undefined,
winnerUsername: winnerScoreChanged && winnerOwnerId ? usernameByUserId.get(winnerOwnerId) : undefined,
loserUsername: showLoser && loserOwnerId ? usernameByUserId.get(loserOwnerId) : undefined,
winnerDiscordUserId: winnerScoreChanged && winnerOwnerId ? discordIdByUserId.get(winnerOwnerId) : undefined,
loserDiscordUserId: showLoser && loserOwnerId ? discordIdByUserId.get(loserOwnerId) : undefined,
};
});
}
@ -1522,6 +1530,7 @@ export async function recalculateAffectedLeagues(
teamId: s.teamId,
teamName: s.team.name,
username: usernameByUserId.get(s.team.ownerId ?? ""),
discordUserId: s.team.ownerId ? discordIdByUserId.get(s.team.ownerId) : undefined,
totalPoints: parseFloat(s.totalPoints),
rank: s.currentRank,
}));

View file

@ -254,6 +254,7 @@ export async function action(args: Route.ActionArgs) {
]),
eventName: "Test Notification",
scoredMatches: [
// discordUserId intentionally omitted — we don't know the commissioner's Discord ID at test time
{ winnerName: "Team Alpha", loserName: "Team Beta", winnerUsername: "manager1", loserUsername: "manager2" },
],
});

View file

@ -195,6 +195,7 @@ describe('Team Management - Admin User List', () => {
avatarType: 'flag',
isAdmin: false,
timezone: null,
discordPingEnabled: false,
lastDataRequestAt: null,
deletedAt: null,
createdAt: new Date(),
@ -213,6 +214,7 @@ describe('Team Management - Admin User List', () => {
avatarType: 'flag',
isAdmin: false,
timezone: null,
discordPingEnabled: false,
lastDataRequestAt: null,
deletedAt: null,
createdAt: new Date(),
@ -252,6 +254,7 @@ describe('Team Management - Admin User List', () => {
avatarType: 'flag',
isAdmin: false,
timezone: null,
discordPingEnabled: false,
lastDataRequestAt: null,
deletedAt: null,
createdAt: new Date(),

View file

@ -1,6 +1,6 @@
import { redirect } from "react-router";
import { useState } from "react";
import { Key, Lock, Shield, User } from "lucide-react";
import { Bell, Key, Lock, Shield, User } from "lucide-react";
import { auth } from "~/lib/auth.server";
import { findUserById, isUserInActiveDraft, updateUser, anonymizeUserAccount } from "~/models/user";
import { findLinkedAccountsByUserId } from "~/models/account";
@ -21,13 +21,15 @@ import { ProfileSection } from "~/components/user/settings/ProfileSection";
import { AccountSection } from "~/components/user/settings/AccountSection";
import { ApiSection } from "~/components/user/settings/ApiSection";
import { PrivacySection } from "~/components/user/settings/PrivacySection";
import { NotificationsSection } from "~/components/user/settings/NotificationsSection";
import type { Route } from "./+types/settings";
type SectionId = "profile" | "account" | "api" | "privacy";
type SectionId = "profile" | "account" | "notifications" | "api" | "privacy";
const SECTIONS: readonly SettingsGridSection[] = [
{ id: "profile", label: "Profile", icon: User, subtitle: "Name, avatar, timezone" },
{ id: "account", label: "Account", icon: Shield, subtitle: "Email, sign-in methods" },
{ id: "notifications", label: "Notifications", icon: Bell, subtitle: "Discord pings" },
{ id: "api", label: "API Access", icon: Key, subtitle: "Coming soon" },
{ id: "privacy", label: "Data & Privacy", icon: Lock, subtitle: "Export, delete account", isDanger: true },
] as const;
@ -42,6 +44,8 @@ type ActionData =
| { intent: "remove-avatar-photo"; success: true }
| { intent: "update-profile"; success: true }
| { intent: "update-profile"; error: string }
| { intent: "update-discord-ping"; success: true }
| { intent: "update-discord-ping"; error: string }
| { intent: "request-data"; dataRequestSuccess: true }
| { intent: "request-data"; dataRequestError: string }
| { intent: string; error: string };
@ -115,18 +119,28 @@ export async function action(args: Route.ActionArgs): Promise<ActionData | Respo
}
if (intent === "update-profile") {
const displayName = formData.get("displayName") as string;
const username = formData.get("username") as string | null;
const timezone = formData.get("timezone") as string | null;
const timezoneValue = timezone && VALID_TIMEZONES.has(timezone) ? timezone : undefined;
await updateUser(session.user.id, {
displayName: displayName || undefined,
username: username || undefined,
timezone: timezoneValue,
});
return { intent: "update-profile", success: true };
}
if (intent === "update-discord-ping") {
const enabled = formData.get("discordPingEnabled") === "true";
if (enabled) {
const accounts = await findLinkedAccountsByUserId(session.user.id);
if (!accounts.some((a) => a.providerId === "discord")) {
return { intent: "update-discord-ping", error: "Link your Discord account first." };
}
}
await updateUser(session.user.id, { discordPingEnabled: enabled });
return { intent: "update-discord-ping", success: true };
}
if (intent === "request-data") {
if (user.lastDataRequestAt) {
const cooldownExpires = new Date(user.lastDataRequestAt.getTime() + DATA_REQUEST_COOLDOWN_MS);
@ -233,6 +247,15 @@ export default function SettingsPage({ loaderData, actionData }: Route.Component
{activeSection === "account" && (
<AccountSection email={user.email} linkedAccounts={linkedAccounts} />
)}
{activeSection === "notifications" && (
<NotificationsSection
discordPingEnabled={user.discordPingEnabled}
hasDiscordLinked={linkedAccounts.some((a) => a.providerId === "discord")}
success={ad?.intent === "update-discord-ping" && "success" in ad}
error={ad?.intent === "update-discord-ping" && "error" in ad ? ad.error : undefined}
onNavigateToAccount={() => handleSectionChange("account")}
/>
)}
{activeSection === "api" && <ApiSection />}
{activeSection === "privacy" && (
<PrivacySection

View file

@ -18,6 +18,10 @@ function getDescription(): string {
return body.embeds[0].description as string;
}
function getPayload(): ReturnType<typeof JSON.parse> {
return JSON.parse((fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body);
}
describe("sendDiscordWebhook", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch(204));
@ -293,6 +297,115 @@ describe("sendStandingsUpdateNotification", () => {
expect(desc).toContain("user\\_two");
});
it("uses Discord mention in standings for opted-in user", async () => {
await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "My League 2025",
standings: [
{
teamId: "a",
teamName: "Alpha FC",
username: "christhrowsrocks",
discordUserId: "111222333",
totalPoints: 150,
rank: 1,
},
],
previousStandings: new Map([["a", 125]]),
});
const desc = getDescription();
expect(desc).toContain("Alpha FC (<@111222333>)");
expect(desc).not.toContain("christhrowsrocks");
});
it("uses Discord mention in scored match for opted-in winner", async () => {
await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "My League 2025",
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }],
previousStandings: new Map([["a", 100]]),
scoredMatches: [
{
winnerName: "Real Madrid",
loserName: "Bayern Munich",
winnerUsername: "manager1",
winnerDiscordUserId: "111222333",
loserUsername: "manager2",
},
],
});
const desc = getDescription();
expect(desc).toContain("Real Madrid (<@111222333>)");
expect(desc).not.toContain("manager1");
expect(desc).toContain("Bayern Munich (manager2)");
});
it("sets content and allowed_mentions when opted-in users are present", async () => {
await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "My League 2025",
standings: [
{
teamId: "a",
teamName: "Alpha FC",
username: "christhrowsrocks",
discordUserId: "111222333",
totalPoints: 150,
rank: 1,
},
],
previousStandings: new Map([["a", 125]]),
});
const payload = getPayload();
expect(payload.content).toBe("<@111222333>");
expect(payload.allowed_mentions).toEqual({ parse: [], users: ["111222333"] });
});
it("omits content and allowed_mentions when no users opted in", async () => {
await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "My League 2025",
standings: [
{ teamId: "a", teamName: "Alpha FC", username: "christhrowsrocks", totalPoints: 150, rank: 1 },
],
previousStandings: new Map([["a", 125]]),
});
const payload = getPayload();
expect(payload.content).toBeUndefined();
expect(payload.allowed_mentions).toBeUndefined();
});
it("collects Discord IDs from both standings and scored matches", async () => {
await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "My League 2025",
standings: [
{ teamId: "a", teamName: "Alpha", username: "user1", discordUserId: "111", totalPoints: 150, rank: 1 },
],
previousStandings: new Map([["a", 125]]),
scoredMatches: [
{
winnerName: "Real Madrid",
loserName: "Bayern Munich",
winnerUsername: "user2",
winnerDiscordUserId: "222",
loserUsername: "user1",
loserDiscordUserId: "111",
},
],
});
const payload = getPayload();
const ids: string[] = payload.allowed_mentions.users;
expect(ids).toHaveLength(2);
expect(ids).toContain("111");
expect(ids).toContain("222");
});
it("does not show fields on the embed", async () => {
await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL,

View file

@ -10,6 +10,7 @@ interface DiscordEmbed {
interface DiscordWebhookPayload {
content?: string;
embeds?: DiscordEmbed[];
allowed_mentions?: { parse: string[]; users: string[] };
}
export async function sendDiscordWebhook(
@ -37,6 +38,7 @@ export interface StandingEntry {
teamId: string;
teamName: string;
username?: string;
discordUserId?: string;
totalPoints: number;
rank: number;
}
@ -46,6 +48,8 @@ export interface ScoredMatch {
loserName: string;
winnerUsername?: string;
loserUsername?: string;
winnerDiscordUserId?: string;
loserDiscordUserId?: string;
}
export async function sendStandingsUpdateNotification({
@ -83,11 +87,21 @@ export async function sendStandingsUpdateNotification({
if (relevantMatches && relevantMatches.length > 0) {
sections.push("\n**Scored Matches**");
for (const match of relevantMatches) {
const winnerLabel = match.winnerUsername
? `${escapeMarkdown(match.winnerName)} (${escapeMarkdown(match.winnerUsername)})`
const winnerManagerLabel = match.winnerDiscordUserId
? `<@${match.winnerDiscordUserId}>`
: match.winnerUsername
? escapeMarkdown(match.winnerUsername)
: undefined;
const loserManagerLabel = match.loserDiscordUserId
? `<@${match.loserDiscordUserId}>`
: match.loserUsername
? escapeMarkdown(match.loserUsername)
: undefined;
const winnerLabel = winnerManagerLabel
? `${escapeMarkdown(match.winnerName)} (${winnerManagerLabel})`
: escapeMarkdown(match.winnerName);
const loserLabel = match.loserUsername
? `${escapeMarkdown(match.loserName)} (${escapeMarkdown(match.loserUsername)})`
const loserLabel = loserManagerLabel
? `${escapeMarkdown(match.loserName)} (${loserManagerLabel})`
: escapeMarkdown(match.loserName);
sections.push(`• **${winnerLabel}** def. ${loserLabel}`);
}
@ -127,8 +141,12 @@ export async function sendStandingsUpdateNotification({
}
const escapedName = escapeMarkdown(s.teamName);
const escapedUsername = s.username ? escapeMarkdown(s.username) : undefined;
const label = escapedUsername ? `${escapedName} (${escapedUsername})` : escapedName;
const managerLabel = s.discordUserId
? `<@${s.discordUserId}>`
: s.username
? escapeMarkdown(s.username)
: undefined;
const label = managerLabel ? `${escapedName} (${managerLabel})` : escapedName;
sections.push(`${rankPrefix}\\. ${label}${Math.round(s.totalPoints)} pts${pointDelta}${rankDelta}`);
}
}
@ -139,7 +157,18 @@ export async function sendStandingsUpdateNotification({
description = description.slice(0, MAX_DESCRIPTION - 3) + "...";
}
await sendDiscordWebhook(webhookUrl, {
// Collect Discord user IDs of all opted-in managers appearing in this notification.
const pingUserIds = new Set<string>();
for (const s of changedTeams) {
if (s.discordUserId) pingUserIds.add(s.discordUserId);
}
for (const m of relevantMatches ?? []) {
if (m.winnerDiscordUserId) pingUserIds.add(m.winnerDiscordUserId);
if (m.loserDiscordUserId) pingUserIds.add(m.loserDiscordUserId);
}
const pingIds = [...pingUserIds];
const payload: DiscordWebhookPayload = {
embeds: [
{
title: `📊 Standings Update — ${seasonName}`,
@ -148,5 +177,14 @@ export async function sendStandingsUpdateNotification({
footer: { text: "brackt.com" },
},
],
});
};
if (pingIds.length > 0) {
// Discord caps allowed_mentions.users at 100; slice to avoid a rejected payload.
const cappedIds = pingIds.slice(0, 100);
payload.content = cappedIds.map((id) => `<@${id}>`).join(" ");
payload.allowed_mentions = { parse: [], users: cappedIds };
}
await sendDiscordWebhook(webhookUrl, payload);
}

View file

@ -7,13 +7,14 @@ export const users = pgTable("users", {
email: varchar("email", { length: 255 }).notNull(),
emailVerified: boolean("email_verified").notNull().default(false),
username: varchar("username", { length: 255 }),
displayName: varchar("display_name", { length: 255 }), // mapped as BetterAuth's "name" field
displayName: varchar("display_name", { length: 255 }), // written by BetterAuth on OAuth login ("name" field); not user-editable
imageUrl: varchar("image_url", { length: 512 }), // mapped as BetterAuth's "image" field
flagConfig: jsonb("flag_config").$type<{ pattern: string; colors: string[] }>(),
customAvatarUrl: varchar("custom_avatar_url", { length: 512 }),
avatarType: varchar("avatar_type", { length: 20 }).notNull().default("flag"),
isAdmin: boolean("is_admin").notNull().default(false),
timezone: varchar("timezone", { length: 50 }), // IANA timezone string, e.g. "America/Chicago"
discordPingEnabled: boolean("discord_ping_enabled").notNull().default(false),
lastDataRequestAt: timestamp("last_data_request_at"),
deletedAt: timestamp("deleted_at"),
createdAt: timestamp("created_at").defaultNow().notNull(),

View file

@ -0,0 +1 @@
ALTER TABLE "users" ADD COLUMN "discord_ping_enabled" boolean DEFAULT false NOT NULL;

File diff suppressed because it is too large Load diff

View file

@ -736,6 +736,13 @@
"when": 1778784280606,
"tag": "0104_chief_boom_boom",
"breakpoints": true
},
{
"idx": 105,
"version": "7",
"when": 1778862483243,
"tag": "0105_mighty_talisman",
"breakpoints": true
}
]
}