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> <div>
<h2 className="text-lg font-semibold">Profile</h2> <h2 className="text-lg font-semibold">Profile</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Your display name, username, avatar, and timezone. Your username, avatar, and timezone.
</p> </p>
</div> </div>
@ -58,14 +58,6 @@ export function ProfileSection({ user, isInActiveDraft, success, error }: Props)
</div> </div>
<Form method="post" className="space-y-4"> <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"> <div className="space-y-2">
<Label htmlFor="username">Username</Label> <Label htmlFor="username">Username</Label>
<Input <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 { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
@ -14,3 +14,13 @@ export async function findLinkedAccountsByUserId(userId: string): Promise<Linked
.from(schema.accounts) .from(schema.accounts)
.where(eq(schema.accounts.userId, userId)); .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 { BRACKET_TEMPLATES } from "~/lib/bracket-templates";
import { doesLoserAdvance } from "~/models/playoff-match"; import { doesLoserAdvance } from "~/models/playoff-match";
import { getUserDisplayName } from "~/models/user"; import { getUserDisplayName } from "~/models/user";
import { findDiscordIdsByUserIds } from "~/models/account";
import { createDailySnapshot } from "~/models/standings"; import { createDailySnapshot } from "~/models/standings";
import { recordMatchScoreEvents } from "~/models/team-score-events"; import { recordMatchScoreEvents } from "~/models/team-score-events";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
@ -1455,6 +1456,12 @@ export async function recalculateAffectedLeagues(
.filter((entry): entry is [string, string] => entry[1] !== null) .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 // Build teamId → ownerId map from standings data
const ownerIdByTeamId = new Map( const ownerIdByTeamId = new Map(
afterStandings.map((s) => [s.teamId, s.team.ownerId]) afterStandings.map((s) => [s.teamId, s.team.ownerId])
@ -1485,13 +1492,11 @@ export async function recalculateAffectedLeagues(
draftPicks.map((p) => [p.participantId, p.teamId]) draftPicks.map((p) => [p.participantId, p.teamId])
); );
const lookupUsername = (participantId: string | null | undefined) => { const lookupOwner = (participantId: string | null | undefined) => {
if (!participantId) return undefined; if (!participantId) return undefined;
const teamId = teamIdByParticipantId.get(participantId); const teamId = teamIdByParticipantId.get(participantId);
if (!teamId) return undefined; if (!teamId) return undefined;
const ownerId = ownerIdByTeamId.get(teamId); return ownerIdByTeamId.get(teamId);
if (!ownerId) return undefined;
return usernameByUserId.get(ownerId);
}; };
scoredMatches = relevant scoredMatches = relevant
@ -1500,13 +1505,16 @@ export async function recalculateAffectedLeagues(
const winnerTeamId = m.winnerId ? teamIdByParticipantId.get(m.winnerId) : undefined; const winnerTeamId = m.winnerId ? teamIdByParticipantId.get(m.winnerId) : undefined;
const loserTeamId = m.loserId ? teamIdByParticipantId.get(m.loserId) : undefined; const loserTeamId = m.loserId ? teamIdByParticipantId.get(m.loserId) : undefined;
const winnerScoreChanged = winnerTeamId ? changedTeamIds.has(winnerTeamId) : false; 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 { return {
winnerName: m.winnerName ?? "", winnerName: m.winnerName ?? "",
loserName: m.loserName ?? "", loserName: m.loserName ?? "",
winnerUsername: winnerScoreChanged ? lookupUsername(m.winnerId) : undefined, winnerUsername: winnerScoreChanged && winnerOwnerId ? usernameByUserId.get(winnerOwnerId) : undefined,
loserUsername: isLoserNotifiable(m.loserId, loserTeamId, changedTeamIds, finalizedLoserIds) loserUsername: showLoser && loserOwnerId ? usernameByUserId.get(loserOwnerId) : undefined,
? lookupUsername(m.loserId) winnerDiscordUserId: winnerScoreChanged && winnerOwnerId ? discordIdByUserId.get(winnerOwnerId) : undefined,
: undefined, loserDiscordUserId: showLoser && loserOwnerId ? discordIdByUserId.get(loserOwnerId) : undefined,
}; };
}); });
} }
@ -1522,6 +1530,7 @@ export async function recalculateAffectedLeagues(
teamId: s.teamId, teamId: s.teamId,
teamName: s.team.name, teamName: s.team.name,
username: usernameByUserId.get(s.team.ownerId ?? ""), username: usernameByUserId.get(s.team.ownerId ?? ""),
discordUserId: s.team.ownerId ? discordIdByUserId.get(s.team.ownerId) : undefined,
totalPoints: parseFloat(s.totalPoints), totalPoints: parseFloat(s.totalPoints),
rank: s.currentRank, rank: s.currentRank,
})); }));

View file

@ -254,6 +254,7 @@ export async function action(args: Route.ActionArgs) {
]), ]),
eventName: "Test Notification", eventName: "Test Notification",
scoredMatches: [ 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" }, { winnerName: "Team Alpha", loserName: "Team Beta", winnerUsername: "manager1", loserUsername: "manager2" },
], ],
}); });

View file

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

View file

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

View file

@ -18,6 +18,10 @@ function getDescription(): string {
return body.embeds[0].description as 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", () => { describe("sendDiscordWebhook", () => {
beforeEach(() => { beforeEach(() => {
vi.stubGlobal("fetch", mockFetch(204)); vi.stubGlobal("fetch", mockFetch(204));
@ -293,6 +297,115 @@ describe("sendStandingsUpdateNotification", () => {
expect(desc).toContain("user\\_two"); 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 () => { it("does not show fields on the embed", async () => {
await sendStandingsUpdateNotification({ await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL, webhookUrl: WEBHOOK_URL,

View file

@ -10,6 +10,7 @@ interface DiscordEmbed {
interface DiscordWebhookPayload { interface DiscordWebhookPayload {
content?: string; content?: string;
embeds?: DiscordEmbed[]; embeds?: DiscordEmbed[];
allowed_mentions?: { parse: string[]; users: string[] };
} }
export async function sendDiscordWebhook( export async function sendDiscordWebhook(
@ -37,6 +38,7 @@ export interface StandingEntry {
teamId: string; teamId: string;
teamName: string; teamName: string;
username?: string; username?: string;
discordUserId?: string;
totalPoints: number; totalPoints: number;
rank: number; rank: number;
} }
@ -46,6 +48,8 @@ export interface ScoredMatch {
loserName: string; loserName: string;
winnerUsername?: string; winnerUsername?: string;
loserUsername?: string; loserUsername?: string;
winnerDiscordUserId?: string;
loserDiscordUserId?: string;
} }
export async function sendStandingsUpdateNotification({ export async function sendStandingsUpdateNotification({
@ -83,11 +87,21 @@ export async function sendStandingsUpdateNotification({
if (relevantMatches && relevantMatches.length > 0) { if (relevantMatches && relevantMatches.length > 0) {
sections.push("\n**Scored Matches**"); sections.push("\n**Scored Matches**");
for (const match of relevantMatches) { for (const match of relevantMatches) {
const winnerLabel = match.winnerUsername const winnerManagerLabel = match.winnerDiscordUserId
? `${escapeMarkdown(match.winnerName)} (${escapeMarkdown(match.winnerUsername)})` ? `<@${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); : escapeMarkdown(match.winnerName);
const loserLabel = match.loserUsername const loserLabel = loserManagerLabel
? `${escapeMarkdown(match.loserName)} (${escapeMarkdown(match.loserUsername)})` ? `${escapeMarkdown(match.loserName)} (${loserManagerLabel})`
: escapeMarkdown(match.loserName); : escapeMarkdown(match.loserName);
sections.push(`• **${winnerLabel}** def. ${loserLabel}`); sections.push(`• **${winnerLabel}** def. ${loserLabel}`);
} }
@ -127,8 +141,12 @@ export async function sendStandingsUpdateNotification({
} }
const escapedName = escapeMarkdown(s.teamName); const escapedName = escapeMarkdown(s.teamName);
const escapedUsername = s.username ? escapeMarkdown(s.username) : undefined; const managerLabel = s.discordUserId
const label = escapedUsername ? `${escapedName} (${escapedUsername})` : escapedName; ? `<@${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}`); 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) + "..."; 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: [ embeds: [
{ {
title: `📊 Standings Update — ${seasonName}`, title: `📊 Standings Update — ${seasonName}`,
@ -148,5 +177,14 @@ export async function sendStandingsUpdateNotification({
footer: { text: "brackt.com" }, 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(), email: varchar("email", { length: 255 }).notNull(),
emailVerified: boolean("email_verified").notNull().default(false), emailVerified: boolean("email_verified").notNull().default(false),
username: varchar("username", { length: 255 }), 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 imageUrl: varchar("image_url", { length: 512 }), // mapped as BetterAuth's "image" field
flagConfig: jsonb("flag_config").$type<{ pattern: string; colors: string[] }>(), flagConfig: jsonb("flag_config").$type<{ pattern: string; colors: string[] }>(),
customAvatarUrl: varchar("custom_avatar_url", { length: 512 }), customAvatarUrl: varchar("custom_avatar_url", { length: 512 }),
avatarType: varchar("avatar_type", { length: 20 }).notNull().default("flag"), avatarType: varchar("avatar_type", { length: 20 }).notNull().default("flag"),
isAdmin: boolean("is_admin").notNull().default(false), isAdmin: boolean("is_admin").notNull().default(false),
timezone: varchar("timezone", { length: 50 }), // IANA timezone string, e.g. "America/Chicago" 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"), lastDataRequestAt: timestamp("last_data_request_at"),
deletedAt: timestamp("deleted_at"), deletedAt: timestamp("deleted_at"),
createdAt: timestamp("created_at").defaultNow().notNull(), 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, "when": 1778784280606,
"tag": "0104_chief_boom_boom", "tag": "0104_chief_boom_boom",
"breakpoints": true "breakpoints": true
},
{
"idx": 105,
"version": "7",
"when": 1778862483243,
"tag": "0105_mighty_talisman",
"breakpoints": true
} }
] ]
} }