Add Discord draft pick announcements

Posts a message to the league Discord webhook each time a pick is made, announcing the picked participant and pinging the next team on the clock if their owner has opted into Discord notifications. Enabled via a separate toggle in league settings (independent from standings update notifications).

https://claude.ai/code/session_01Tvwsv3LfL9JUqxoLct8dTn
This commit is contained in:
Claude 2026-05-20 23:17:02 +00:00
parent b8b58ec9f8
commit ba606d94be
No known key found for this signature in database
14 changed files with 6548 additions and 14 deletions

View file

@ -1,6 +1,7 @@
import { Bell } from "lucide-react";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Switch } from "~/components/ui/switch";
import { SettingsSection, SettingsStatusPill } from "./SettingsSection";
export function NotificationsSection({
@ -8,11 +9,15 @@ export function NotificationsSection({
savedDiscordWebhookUrl,
discordWebhookUrl,
onDiscordWebhookUrlChange,
discordPicksAnnouncementEnabled,
onDiscordPicksAnnouncementEnabledChange,
}: {
active: boolean;
savedDiscordWebhookUrl: string | null;
discordWebhookUrl: string;
onDiscordWebhookUrlChange: (v: string) => void;
discordPicksAnnouncementEnabled: boolean;
onDiscordPicksAnnouncementEnabledChange: (v: boolean) => void;
}) {
return (
<SettingsSection
@ -23,19 +28,39 @@ export function NotificationsSection({
status={<SettingsStatusPill tone={savedDiscordWebhookUrl ? "success" : "warning"}>{savedDiscordWebhookUrl ? "Configured" : "Optional"}</SettingsStatusPill>}
className={active ? undefined : "hidden"}
>
<div className="space-y-2">
<Label htmlFor="discordWebhookUrl">Discord Webhook URL</Label>
<Input
id="discordWebhookUrl"
name="discordWebhookUrl"
type="url"
value={discordWebhookUrl}
onChange={(e) => onDiscordWebhookUrlChange(e.target.value)}
placeholder="https://discord.com/api/webhooks/..."
/>
<p className="text-sm text-muted-foreground">
Create a webhook in Discord under Server Settings, Integrations, Webhooks.
</p>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="discordWebhookUrl">Discord Webhook URL</Label>
<Input
id="discordWebhookUrl"
name="discordWebhookUrl"
type="url"
value={discordWebhookUrl}
onChange={(e) => onDiscordWebhookUrlChange(e.target.value)}
placeholder="https://discord.com/api/webhooks/..."
/>
<p className="text-sm text-muted-foreground">
Create a webhook in Discord under Server Settings, Integrations, Webhooks.
</p>
</div>
{savedDiscordWebhookUrl && (
<div className="flex items-center gap-3">
<Switch
id="discordPicksAnnouncementEnabled"
name="discordPicksAnnouncementEnabled"
checked={discordPicksAnnouncementEnabled}
onCheckedChange={onDiscordPicksAnnouncementEnabledChange}
/>
<div>
<Label htmlFor="discordPicksAnnouncementEnabled" className="cursor-pointer">
Announce draft picks
</Label>
<p className="text-sm text-muted-foreground">
Post a message for each pick and ping the next team's owner if they've enabled Discord notifications.
</p>
</div>
</div>
)}
</div>
</SettingsSection>
);

View file

@ -9,6 +9,7 @@ import { getParticipantsForSeasonWithSports } from "./season-participant";
import { getSeasonSportsSimple } from "./season-sport";
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
import { sendOnTheClockEmail } from "~/services/draft-email.server";
import { notifyPickMadeOnDiscord } from "~/services/draft-discord.server";
import { getSocketIO, scheduleDraftRoomClosure } from "../../server/socket";
import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server";
@ -841,6 +842,27 @@ export async function executeAutoPick(params: {
.catch((err) => logger.error("[AutoPick] On-the-clock email failed:", err));
}
{
const pickedSlot = draftSlots.find((s) => s.teamId === teamId);
let nextDraftSlotTeamId: string | null = null;
if (!isDraftComplete) {
const { pickInRound: nextPickInRound } = calculatePickInfo(nextPickNumber, totalTeams);
nextDraftSlotTeamId = draftSlots.find((s) => s.draftOrder === nextPickInRound)?.teamId ?? null;
}
notifyPickMadeOnDiscord({
seasonId,
pickedTeamName: pickedSlot?.team.name ?? "",
participantName: participantToPick.name,
sportName: participantToPick.sportsSeason.sport.name,
pickNumber,
round: currentRound,
pickInRound,
isDraftComplete,
nextDraftSlotTeamId,
db,
}).catch((err) => logger.error("[AutoPick] Discord pick announcement failed:", err));
}
return {
success: true,
pick: draftPick,

View file

@ -78,6 +78,7 @@ export async function findLeaguesWithActiveSeasonsByUserId(
currentSeasonId: schema.leagues.currentSeasonId,
isPublicDraftBoard: schema.leagues.isPublicDraftBoard,
discordWebhookUrl: schema.leagues.discordWebhookUrl,
discordPicksAnnouncementEnabled: schema.leagues.discordPicksAnnouncementEnabled,
createdAt: schema.leagues.createdAt,
updatedAt: schema.leagues.updatedAt,
};

View file

@ -12,6 +12,7 @@ import { getSocketIO, scheduleDraftRoomClosure } from "../../../server/socket";
import { logger } from "~/lib/logger";
import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server";
import { sendOnTheClockEmail } from "~/services/draft-email.server";
import { notifyPickMadeOnDiscord } from "~/services/draft-discord.server";
import type { ActionFunctionArgs } from "react-router";
export async function action(args: ActionFunctionArgs) {
@ -330,6 +331,26 @@ export async function action(args: ActionFunctionArgs) {
.catch((err) => logger.error("On-the-clock email failed:", err));
}
{
let nextDraftSlotTeamId: string | null = null;
if (!isDraftComplete) {
const { pickInRound: nextPickInRound } = calculatePickInfo(nextPickNumber, totalTeams);
nextDraftSlotTeamId = draftSlots.find((s) => s.draftOrder === nextPickInRound)?.teamId ?? null;
}
notifyPickMadeOnDiscord({
seasonId,
pickedTeamName: currentDraftSlot.team.name,
participantName: participant.name,
sportName: participant.sportsSeason.sport.name,
pickNumber: currentPickNumber,
round: currentRound,
pickInRound,
isDraftComplete,
nextDraftSlotTeamId,
db,
}).catch((err) => logger.error("Discord pick announcement failed:", err));
}
return Response.json({
success: true,
pick: draftPick,

View file

@ -170,6 +170,7 @@ export async function action(args: Route.ActionArgs) {
const name = formData.get("name");
const isPublicDraftBoard = formData.get("isPublicDraftBoard") === "on";
const discordWebhookUrl = formData.get("discordWebhookUrl");
const discordPicksAnnouncementEnabled = formData.get("discordPicksAnnouncementEnabled") === "on";
if (typeof name !== "string" || !name.trim()) {
return { error: "League name is required" };
@ -189,11 +190,13 @@ export async function action(args: Route.ActionArgs) {
if (name.trim() !== league.name) changedFields.push("name");
if (isPublicDraftBoard !== league.isPublicDraftBoard) changedFields.push("isPublicDraftBoard");
if ((webhookUrl || null) !== league.discordWebhookUrl) changedFields.push("discordWebhookUrl");
if (discordPicksAnnouncementEnabled !== league.discordPicksAnnouncementEnabled) changedFields.push("discordPicksAnnouncementEnabled");
await updateLeague(leagueId, {
name: name.trim(),
isPublicDraftBoard,
discordWebhookUrl: webhookUrl || null,
discordPicksAnnouncementEnabled,
});
if (changedFields.length > 0) {
@ -210,11 +213,13 @@ export async function action(args: Route.ActionArgs) {
name: league.name,
isPublicDraftBoard: league.isPublicDraftBoard,
discordWebhookUrl: league.discordWebhookUrl,
discordPicksAnnouncementEnabled: league.discordPicksAnnouncementEnabled,
},
newValues: {
name: name.trim(),
isPublicDraftBoard,
discordWebhookUrl: webhookUrl || null,
discordPicksAnnouncementEnabled,
},
},
});

View file

@ -134,6 +134,7 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
const [leagueName, setLeagueName] = useState(league.name);
const [isPublicDraftBoard, setIsPublicDraftBoard] = useState(league.isPublicDraftBoard);
const [discordWebhookUrl, setDiscordWebhookUrl] = useState(league.discordWebhookUrl ?? "");
const [discordPicksAnnouncementEnabled, setDiscordPicksAnnouncementEnabled] = useState(league.discordPicksAnnouncementEnabled);
const [teamCountValue, setTeamCountValue] = useState<number>(teamCount);
const [timerMode, setTimerMode] = useState<"chess_clock" | "standard">(season?.draftTimerMode ?? "chess_clock");
const [overnightMode, setOvernightMode] = useState<"none" | "league" | "per_user">(season?.overnightPauseMode ?? "none");
@ -240,6 +241,7 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
setLeagueName(league.name);
setIsPublicDraftBoard(league.isPublicDraftBoard);
setDiscordWebhookUrl(league.discordWebhookUrl ?? "");
setDiscordPicksAnnouncementEnabled(league.discordPicksAnnouncementEnabled);
setTeamCountValue(teamCount);
setTimerMode(season?.draftTimerMode ?? "chess_clock");
setDraftSpeed(getInitialDraftSpeed(season));
@ -427,6 +429,8 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
savedDiscordWebhookUrl={league.discordWebhookUrl}
discordWebhookUrl={discordWebhookUrl}
onDiscordWebhookUrlChange={(v) => { setDiscordWebhookUrl(v); markDirty(); }}
discordPicksAnnouncementEnabled={discordPicksAnnouncementEnabled}
onDiscordPicksAnnouncementEnabledChange={(v) => { setDiscordPicksAnnouncementEnabled(v); markDirty(); }}
/>
<SettingsMessage actionData={actionData} />

View file

@ -40,6 +40,7 @@ const mockLeague = {
currentSeasonId: 'season-1',
isPublicDraftBoard: false,
discordWebhookUrl: null,
discordPicksAnnouncementEnabled: false,
createdAt: new Date('2025-01-01'),
updatedAt: new Date('2025-01-01'),
};
@ -519,3 +520,42 @@ describe('Settings - Draft Order Action Responses', () => {
expect(isDraftOrderMessage).toBe(true);
});
});
describe('discordPicksAnnouncementEnabled setting', () => {
it('parses discordPicksAnnouncementEnabled as true when form value is "on"', () => {
const formData = new FormData();
formData.set('discordPicksAnnouncementEnabled', 'on');
const value = formData.get('discordPicksAnnouncementEnabled') === 'on';
expect(value).toBe(true);
});
it('parses discordPicksAnnouncementEnabled as false when field is absent', () => {
const formData = new FormData();
const value = formData.get('discordPicksAnnouncementEnabled') === 'on';
expect(value).toBe(false);
});
it('updateLeague is called with discordPicksAnnouncementEnabled: true', async () => {
vi.mocked(updateLeague).mockResolvedValue({ ...mockLeague, discordPicksAnnouncementEnabled: true });
await updateLeague('league-1', { name: 'Test League', isPublicDraftBoard: false, discordPicksAnnouncementEnabled: true });
expect(updateLeague).toHaveBeenCalledWith('league-1', {
name: 'Test League',
isPublicDraftBoard: false,
discordPicksAnnouncementEnabled: true,
});
});
it('updateLeague is called with discordPicksAnnouncementEnabled: false', async () => {
vi.mocked(updateLeague).mockResolvedValue({ ...mockLeague, discordPicksAnnouncementEnabled: false });
await updateLeague('league-1', { name: 'Test League', isPublicDraftBoard: false, discordPicksAnnouncementEnabled: false });
expect(updateLeague).toHaveBeenCalledWith('league-1', {
name: 'Test League',
isPublicDraftBoard: false,
discordPicksAnnouncementEnabled: false,
});
});
});

View file

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { sendDiscordWebhook, sendStandingsUpdateNotification, sendDraftOrderNotification } from "../discord";
import { sendDiscordWebhook, sendStandingsUpdateNotification, sendDraftOrderNotification, sendPickAnnouncementNotification } from "../discord";
const WEBHOOK_URL = "https://discord.com/api/webhooks/123/abc";
@ -587,3 +587,96 @@ describe("sendDraftOrderNotification", () => {
expect(payload.embeds[0].description).not.toContain("(");
});
});
describe("sendPickAnnouncementNotification", () => {
const BASE = {
webhookUrl: WEBHOOK_URL,
draftUrl: "https://brackt.com/leagues/abc/draft/season-1",
pickNumber: 5,
round: 1,
pickInRound: 5,
pickedTeamName: "Alpha FC",
participantName: "Erling Haaland",
sportName: "Soccer",
isDraftComplete: false,
};
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch(204));
});
it("sets embed title with pick and round info", async () => {
await sendPickAnnouncementNotification(BASE);
const payload = getPayload();
expect(payload.embeds[0].title).toBe("Pick #5 — Round 1, Pick 5");
});
it("sets embed url to the draft room link", async () => {
await sendPickAnnouncementNotification(BASE);
const payload = getPayload();
expect(payload.embeds[0].url).toBe(BASE.draftUrl);
});
it("description contains team, participant, and sport", async () => {
await sendPickAnnouncementNotification(BASE);
const desc = getDescription();
expect(desc).toContain("**Alpha FC**");
expect(desc).toContain("**Erling Haaland**");
expect(desc).toContain("(Soccer)");
});
it("uses Discord blurple color and no footer", async () => {
await sendPickAnnouncementNotification(BASE);
const payload = getPayload();
expect(payload.embeds[0].color).toBe(0x5865f2);
expect(payload.embeds[0].footer).toBeUndefined();
});
it("shows next team when provided", async () => {
await sendPickAnnouncementNotification({ ...BASE, nextTeamName: "Beta United" });
const desc = getDescription();
expect(desc).toContain("On the clock: **Beta United**");
});
it("pings next owner with Discord mention when Discord ID is provided", async () => {
await sendPickAnnouncementNotification({
...BASE,
nextTeamName: "Beta United",
nextOwnerDiscordId: "999888777",
});
const desc = getDescription();
expect(desc).toContain("<@999888777>");
const payload = getPayload();
expect(payload.content).toBe("<@999888777>");
expect(payload.allowed_mentions).toEqual({ parse: [], users: ["999888777"] });
});
it("shows team name without mention when no Discord ID", async () => {
await sendPickAnnouncementNotification({ ...BASE, nextTeamName: "Beta United" });
const payload = getPayload();
expect(payload.content).toBeUndefined();
expect(payload.allowed_mentions).toBeUndefined();
});
it("shows draft complete message when isDraftComplete is true", async () => {
await sendPickAnnouncementNotification({ ...BASE, isDraftComplete: true });
const desc = getDescription();
expect(desc).toContain("The draft is complete!");
expect(desc).not.toContain("On the clock");
const payload = getPayload();
expect(payload.content).toBeUndefined();
expect(payload.allowed_mentions).toBeUndefined();
});
it("escapes markdown in team and participant names", async () => {
await sendPickAnnouncementNotification({
...BASE,
pickedTeamName: "Alpha_FC",
participantName: "Player*Name",
sportName: "E-Sports",
});
const desc = getDescription();
expect(desc).toContain("Alpha\\_FC");
expect(desc).toContain("Player\\*Name");
});
});

View file

@ -190,6 +190,65 @@ export async function sendStandingsUpdateNotification({
await sendDiscordWebhook(webhookUrl, payload);
}
export async function sendPickAnnouncementNotification({
webhookUrl,
draftUrl,
pickNumber,
round,
pickInRound,
pickedTeamName,
participantName,
sportName,
nextTeamName,
nextOwnerDiscordId,
isDraftComplete,
}: {
webhookUrl: string;
draftUrl: string;
pickNumber: number;
round: number;
pickInRound: number;
pickedTeamName: string;
participantName: string;
sportName: string;
nextTeamName?: string;
nextOwnerDiscordId?: string;
isDraftComplete: boolean;
}): Promise<void> {
const lines: string[] = [
`**${escapeMarkdown(pickedTeamName)}** selected **${escapeMarkdown(participantName)}** (${escapeMarkdown(sportName)})`,
`Round ${round}, Pick ${pickInRound}`,
];
if (isDraftComplete) {
lines.push("", "The draft is complete!");
} else if (nextTeamName) {
const nextLabel = nextOwnerDiscordId
? `<@${nextOwnerDiscordId}>`
: escapeMarkdown(nextTeamName);
lines.push("", `On the clock: **${escapeMarkdown(nextTeamName)}** (${nextLabel})`);
}
const description = lines.join("\n");
const payload: DiscordWebhookPayload = {
embeds: [
{
title: `Pick #${pickNumber} — Round ${round}, Pick ${pickInRound}`,
url: draftUrl,
description,
color: 0x5865f2,
},
],
};
if (nextOwnerDiscordId && !isDraftComplete) {
payload.content = `<@${nextOwnerDiscordId}>`;
payload.allowed_mentions = { parse: [], users: [nextOwnerDiscordId] };
}
await sendDiscordWebhook(webhookUrl, payload);
}
export async function sendDraftOrderNotification({
webhookUrl,
leagueName,

View file

@ -0,0 +1,84 @@
import { eq } from "drizzle-orm";
import * as schema from "~/database/schema";
import type { database } from "~/database/context";
import { findDiscordIdsByUserIds } from "~/models/account";
import { sendPickAnnouncementNotification } from "~/services/discord";
import { logger } from "~/lib/logger";
export async function notifyPickMadeOnDiscord(params: {
seasonId: string;
pickedTeamName: string;
participantName: string;
sportName: string;
pickNumber: number;
round: number;
pickInRound: number;
isDraftComplete: boolean;
nextDraftSlotTeamId: string | null;
db: ReturnType<typeof database>;
}): Promise<void> {
const {
seasonId,
pickedTeamName,
participantName,
sportName,
pickNumber,
round,
pickInRound,
isDraftComplete,
nextDraftSlotTeamId,
db,
} = params;
try {
const season = await db.query.seasons.findFirst({
where: eq(schema.seasons.id, seasonId),
});
if (!season) return;
const league = await db.query.leagues.findFirst({
where: eq(schema.leagues.id, season.leagueId),
});
if (!league?.discordWebhookUrl || !league.discordPicksAnnouncementEnabled) return;
const appUrl = process.env.APP_URL ?? "https://brackt.com";
const draftUrl = `${appUrl}/leagues/${season.leagueId}/draft/${seasonId}`;
let nextTeamName: string | undefined;
let nextOwnerDiscordId: string | undefined;
if (!isDraftComplete && nextDraftSlotTeamId) {
const nextTeam = await db.query.teams.findFirst({
where: eq(schema.teams.id, nextDraftSlotTeamId),
});
if (nextTeam) {
nextTeamName = nextTeam.name;
if (nextTeam.ownerId) {
const owner = await db.query.users.findFirst({
where: eq(schema.users.id, nextTeam.ownerId),
});
if (owner?.discordPingEnabled) {
const discordIds = await findDiscordIdsByUserIds([owner.id]);
nextOwnerDiscordId = discordIds.get(owner.id);
}
}
}
}
await sendPickAnnouncementNotification({
webhookUrl: league.discordWebhookUrl,
draftUrl,
pickNumber,
round,
pickInRound,
pickedTeamName,
participantName,
sportName,
nextTeamName,
nextOwnerDiscordId,
isDraftComplete,
});
} catch (err) {
logger.error("[DiscordPick] Failed to send pick announcement:", err);
}
}

View file

@ -181,6 +181,7 @@ export const leagues = pgTable("leagues", {
currentSeasonId: uuid("current_season_id"), // References the active season
isPublicDraftBoard: boolean("is_public_draft_board").notNull().default(false),
discordWebhookUrl: text("discord_webhook_url"),
discordPicksAnnouncementEnabled: boolean("discord_picks_announcement_enabled").notNull().default(false),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});

View file

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

File diff suppressed because it is too large Load diff

View file

@ -785,6 +785,13 @@
"when": 1779312132517,
"tag": "0111_gorgeous_captain_universe",
"breakpoints": true
},
{
"idx": 112,
"version": "7",
"when": 1779318579169,
"tag": "0112_married_captain_midlands",
"breakpoints": true
}
]
}