Add Discord draft pick announcements (#460)

* 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

* Address code review feedback on Discord pick announcements

- Fix "on the clock" timing: read currentPickNumber fresh from DB post-autodraft-chain (matches sendOnTheClockEmail guarantee)
- Remove outer try/catch from notifyPickMadeOnDiscord so callers' .catch() is not dead code
- Add missing draft-discord.server.test.ts with 12 tests covering all early-exit and happy paths
- Fix silent empty-string fallback for missing pickedSlot: warn and skip instead
- Eliminate sequential season→league DB queries by accepting leagueId as a direct param
- Show "save webhook URL to configure options" hint when URL is typed but not yet saved
- Remove block-scope braces at both call sites (plain const declarations)
- Remove redundant "Round N, Pick M" description line (title already carries this info)
- Inline pickInRoundFor helper to avoid circular import with draft-utils

https://claude.ai/code/session_01Tvwsv3LfL9JUqxoLct8dTn

* Fix lint errors from review fixes

- Remove unused logger import (no longer needed after removing try/catch)
- Remove unused OWNER_ID constant in test fixture
- Use toSorted() instead of sort() in sendDraftOrderNotification

https://claude.ai/code/session_01Tvwsv3LfL9JUqxoLct8dTn

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-05-20 19:55:48 -07:00 committed by GitHub
parent b8b58ec9f8
commit fbcecb490e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 6796 additions and 15 deletions

View file

@ -1,6 +1,7 @@
import { Bell } from "lucide-react"; import { Bell } from "lucide-react";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
import { Switch } from "~/components/ui/switch";
import { SettingsSection, SettingsStatusPill } from "./SettingsSection"; import { SettingsSection, SettingsStatusPill } from "./SettingsSection";
export function NotificationsSection({ export function NotificationsSection({
@ -8,11 +9,15 @@ export function NotificationsSection({
savedDiscordWebhookUrl, savedDiscordWebhookUrl,
discordWebhookUrl, discordWebhookUrl,
onDiscordWebhookUrlChange, onDiscordWebhookUrlChange,
discordPicksAnnouncementEnabled,
onDiscordPicksAnnouncementEnabledChange,
}: { }: {
active: boolean; active: boolean;
savedDiscordWebhookUrl: string | null; savedDiscordWebhookUrl: string | null;
discordWebhookUrl: string; discordWebhookUrl: string;
onDiscordWebhookUrlChange: (v: string) => void; onDiscordWebhookUrlChange: (v: string) => void;
discordPicksAnnouncementEnabled: boolean;
onDiscordPicksAnnouncementEnabledChange: (v: boolean) => void;
}) { }) {
return ( return (
<SettingsSection <SettingsSection
@ -23,19 +28,43 @@ export function NotificationsSection({
status={<SettingsStatusPill tone={savedDiscordWebhookUrl ? "success" : "warning"}>{savedDiscordWebhookUrl ? "Configured" : "Optional"}</SettingsStatusPill>} status={<SettingsStatusPill tone={savedDiscordWebhookUrl ? "success" : "warning"}>{savedDiscordWebhookUrl ? "Configured" : "Optional"}</SettingsStatusPill>}
className={active ? undefined : "hidden"} className={active ? undefined : "hidden"}
> >
<div className="space-y-2"> <div className="space-y-4">
<Label htmlFor="discordWebhookUrl">Discord Webhook URL</Label> <div className="space-y-2">
<Input <Label htmlFor="discordWebhookUrl">Discord Webhook URL</Label>
id="discordWebhookUrl" <Input
name="discordWebhookUrl" id="discordWebhookUrl"
type="url" name="discordWebhookUrl"
value={discordWebhookUrl} type="url"
onChange={(e) => onDiscordWebhookUrlChange(e.target.value)} value={discordWebhookUrl}
placeholder="https://discord.com/api/webhooks/..." 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 className="text-sm text-muted-foreground">
</p> 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>
) : discordWebhookUrl ? (
<p className="text-sm text-muted-foreground">
Save the webhook URL above to configure additional notification options.
</p>
) : null}
</div> </div>
</SettingsSection> </SettingsSection>
); );

View file

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

View file

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

View file

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

View file

@ -170,6 +170,7 @@ export async function action(args: Route.ActionArgs) {
const name = formData.get("name"); const name = formData.get("name");
const isPublicDraftBoard = formData.get("isPublicDraftBoard") === "on"; const isPublicDraftBoard = formData.get("isPublicDraftBoard") === "on";
const discordWebhookUrl = formData.get("discordWebhookUrl"); const discordWebhookUrl = formData.get("discordWebhookUrl");
const discordPicksAnnouncementEnabled = formData.get("discordPicksAnnouncementEnabled") === "on";
if (typeof name !== "string" || !name.trim()) { if (typeof name !== "string" || !name.trim()) {
return { error: "League name is required" }; 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 (name.trim() !== league.name) changedFields.push("name");
if (isPublicDraftBoard !== league.isPublicDraftBoard) changedFields.push("isPublicDraftBoard"); if (isPublicDraftBoard !== league.isPublicDraftBoard) changedFields.push("isPublicDraftBoard");
if ((webhookUrl || null) !== league.discordWebhookUrl) changedFields.push("discordWebhookUrl"); if ((webhookUrl || null) !== league.discordWebhookUrl) changedFields.push("discordWebhookUrl");
if (discordPicksAnnouncementEnabled !== league.discordPicksAnnouncementEnabled) changedFields.push("discordPicksAnnouncementEnabled");
await updateLeague(leagueId, { await updateLeague(leagueId, {
name: name.trim(), name: name.trim(),
isPublicDraftBoard, isPublicDraftBoard,
discordWebhookUrl: webhookUrl || null, discordWebhookUrl: webhookUrl || null,
discordPicksAnnouncementEnabled,
}); });
if (changedFields.length > 0) { if (changedFields.length > 0) {
@ -210,11 +213,13 @@ export async function action(args: Route.ActionArgs) {
name: league.name, name: league.name,
isPublicDraftBoard: league.isPublicDraftBoard, isPublicDraftBoard: league.isPublicDraftBoard,
discordWebhookUrl: league.discordWebhookUrl, discordWebhookUrl: league.discordWebhookUrl,
discordPicksAnnouncementEnabled: league.discordPicksAnnouncementEnabled,
}, },
newValues: { newValues: {
name: name.trim(), name: name.trim(),
isPublicDraftBoard, isPublicDraftBoard,
discordWebhookUrl: webhookUrl || null, 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 [leagueName, setLeagueName] = useState(league.name);
const [isPublicDraftBoard, setIsPublicDraftBoard] = useState(league.isPublicDraftBoard); const [isPublicDraftBoard, setIsPublicDraftBoard] = useState(league.isPublicDraftBoard);
const [discordWebhookUrl, setDiscordWebhookUrl] = useState(league.discordWebhookUrl ?? ""); const [discordWebhookUrl, setDiscordWebhookUrl] = useState(league.discordWebhookUrl ?? "");
const [discordPicksAnnouncementEnabled, setDiscordPicksAnnouncementEnabled] = useState(league.discordPicksAnnouncementEnabled);
const [teamCountValue, setTeamCountValue] = useState<number>(teamCount); const [teamCountValue, setTeamCountValue] = useState<number>(teamCount);
const [timerMode, setTimerMode] = useState<"chess_clock" | "standard">(season?.draftTimerMode ?? "chess_clock"); const [timerMode, setTimerMode] = useState<"chess_clock" | "standard">(season?.draftTimerMode ?? "chess_clock");
const [overnightMode, setOvernightMode] = useState<"none" | "league" | "per_user">(season?.overnightPauseMode ?? "none"); 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); setLeagueName(league.name);
setIsPublicDraftBoard(league.isPublicDraftBoard); setIsPublicDraftBoard(league.isPublicDraftBoard);
setDiscordWebhookUrl(league.discordWebhookUrl ?? ""); setDiscordWebhookUrl(league.discordWebhookUrl ?? "");
setDiscordPicksAnnouncementEnabled(league.discordPicksAnnouncementEnabled);
setTeamCountValue(teamCount); setTeamCountValue(teamCount);
setTimerMode(season?.draftTimerMode ?? "chess_clock"); setTimerMode(season?.draftTimerMode ?? "chess_clock");
setDraftSpeed(getInitialDraftSpeed(season)); setDraftSpeed(getInitialDraftSpeed(season));
@ -427,6 +429,8 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
savedDiscordWebhookUrl={league.discordWebhookUrl} savedDiscordWebhookUrl={league.discordWebhookUrl}
discordWebhookUrl={discordWebhookUrl} discordWebhookUrl={discordWebhookUrl}
onDiscordWebhookUrlChange={(v) => { setDiscordWebhookUrl(v); markDirty(); }} onDiscordWebhookUrlChange={(v) => { setDiscordWebhookUrl(v); markDirty(); }}
discordPicksAnnouncementEnabled={discordPicksAnnouncementEnabled}
onDiscordPicksAnnouncementEnabledChange={(v) => { setDiscordPicksAnnouncementEnabled(v); markDirty(); }}
/> />
<SettingsMessage actionData={actionData} /> <SettingsMessage actionData={actionData} />

View file

@ -40,6 +40,7 @@ const mockLeague = {
currentSeasonId: 'season-1', currentSeasonId: 'season-1',
isPublicDraftBoard: false, isPublicDraftBoard: false,
discordWebhookUrl: null, discordWebhookUrl: null,
discordPicksAnnouncementEnabled: false,
createdAt: new Date('2025-01-01'), createdAt: new Date('2025-01-01'),
updatedAt: 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); 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 { 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"; const WEBHOOK_URL = "https://discord.com/api/webhooks/123/abc";
@ -587,3 +587,96 @@ describe("sendDraftOrderNotification", () => {
expect(payload.embeds[0].description).not.toContain("("); 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

@ -0,0 +1,233 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
vi.mock("~/services/discord", () => ({
sendPickAnnouncementNotification: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("~/models/account", () => ({
findDiscordIdsByUserIds: vi.fn().mockResolvedValue(new Map()),
}));
import { notifyPickMadeOnDiscord } from "../draft-discord.server";
import { sendPickAnnouncementNotification } from "~/services/discord";
import { findDiscordIdsByUserIds } from "~/models/account";
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const SEASON_ID = "season-1";
const LEAGUE_ID = "league-1";
const TEAM_ID = "team-1";
const NEXT_TEAM_ID = "team-2";
const NEXT_OWNER_ID = "owner-2";
const WEBHOOK_URL = "https://discord.com/api/webhooks/123/abc";
/** 2-team draft; snake: pick 1→team1, pick 2→team2, pick 3→team2, pick 4→team1 */
const DRAFT_SLOTS = [
{ teamId: TEAM_ID, draftOrder: 1 },
{ teamId: NEXT_TEAM_ID, draftOrder: 2 },
];
const BASE_PARAMS = {
seasonId: SEASON_ID,
leagueId: LEAGUE_ID,
pickedTeamName: "Alpha FC",
participantName: "Erling Haaland",
sportName: "Soccer",
pickNumber: 1,
round: 1,
pickInRound: 1,
isDraftComplete: false,
totalTeams: 2,
draftSlots: DRAFT_SLOTS,
};
function makeLeague(overrides: object = {}) {
return {
id: LEAGUE_ID,
discordWebhookUrl: WEBHOOK_URL,
discordPicksAnnouncementEnabled: true,
...overrides,
};
}
function makeSeason(currentPickNumber = 2) {
return { id: SEASON_ID, leagueId: LEAGUE_ID, currentPickNumber };
}
function makeTeam(id: string, ownerId: string | null = null, name = "Beta United") {
return { id, name, ownerId };
}
function makeOwner(id: string, discordPingEnabled = true) {
return { id, discordPingEnabled };
}
function makeMockDb(overrides: {
league?: object | null;
season?: object | null;
nextTeam?: object | null;
owner?: object | null;
} = {}) {
const league = "league" in overrides ? overrides.league : makeLeague();
const season = "season" in overrides ? overrides.season : makeSeason();
const nextTeam = "nextTeam" in overrides ? overrides.nextTeam : makeTeam(NEXT_TEAM_ID, NEXT_OWNER_ID);
const owner = "owner" in overrides ? overrides.owner : makeOwner(NEXT_OWNER_ID);
return {
query: {
leagues: { findFirst: vi.fn().mockResolvedValue(league) },
seasons: { findFirst: vi.fn().mockResolvedValue(season) },
teams: { findFirst: vi.fn().mockResolvedValue(nextTeam) },
users: { findFirst: vi.fn().mockResolvedValue(owner) },
},
};
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
beforeEach(() => {
vi.clearAllMocks();
process.env.APP_URL = "https://test.brackt.com";
});
afterEach(() => {
delete process.env.APP_URL;
});
describe("notifyPickMadeOnDiscord", () => {
it("returns early when league has no webhook URL", async () => {
const db = makeMockDb({ league: makeLeague({ discordWebhookUrl: null }) });
await notifyPickMadeOnDiscord({ ...BASE_PARAMS, db: db as never });
expect(sendPickAnnouncementNotification).not.toHaveBeenCalled();
});
it("returns early when picks announcement toggle is disabled", async () => {
const db = makeMockDb({ league: makeLeague({ discordPicksAnnouncementEnabled: false }) });
await notifyPickMadeOnDiscord({ ...BASE_PARAMS, db: db as never });
expect(sendPickAnnouncementNotification).not.toHaveBeenCalled();
});
it("returns early when league is not found", async () => {
const db = makeMockDb({ league: null });
await notifyPickMadeOnDiscord({ ...BASE_PARAMS, db: db as never });
expect(sendPickAnnouncementNotification).not.toHaveBeenCalled();
});
it("sends announcement with correct pick details", async () => {
const db = makeMockDb();
await notifyPickMadeOnDiscord({ ...BASE_PARAMS, db: db as never });
expect(sendPickAnnouncementNotification).toHaveBeenCalledWith(
expect.objectContaining({
webhookUrl: WEBHOOK_URL,
pickedTeamName: "Alpha FC",
participantName: "Erling Haaland",
sportName: "Soccer",
pickNumber: 1,
round: 1,
pickInRound: 1,
isDraftComplete: false,
})
);
});
it("builds the draft URL from APP_URL env var", async () => {
const db = makeMockDb();
await notifyPickMadeOnDiscord({ ...BASE_PARAMS, db: db as never });
expect(sendPickAnnouncementNotification).toHaveBeenCalledWith(
expect.objectContaining({
draftUrl: `https://test.brackt.com/leagues/${LEAGUE_ID}/draft/${SEASON_ID}`,
})
);
});
it("passes nextTeamName when fresh season resolves the next slot", async () => {
const db = makeMockDb({ season: makeSeason(2) }); // currentPickNumber=2 → pickInRound=2 → NEXT_TEAM_ID
await notifyPickMadeOnDiscord({ ...BASE_PARAMS, db: db as never });
expect(sendPickAnnouncementNotification).toHaveBeenCalledWith(
expect.objectContaining({ nextTeamName: "Beta United" })
);
});
it("reads next team from fresh DB pick number, not caller-supplied snapshot", async () => {
// Simulate autodraft chain advancing 2 picks: currentPickNumber=3 on a 2-team snake
// draft means pickInRound=2 again (round 2 reversed) → NEXT_TEAM_ID
const db = makeMockDb({ season: makeSeason(3) });
await notifyPickMadeOnDiscord({ ...BASE_PARAMS, db: db as never });
expect(db.query.seasons.findFirst).toHaveBeenCalledTimes(1);
});
it("passes nextOwnerDiscordId when owner has discordPingEnabled", async () => {
vi.mocked(findDiscordIdsByUserIds).mockResolvedValue(new Map([[NEXT_OWNER_ID, "discord-999"]]));
const db = makeMockDb();
await notifyPickMadeOnDiscord({ ...BASE_PARAMS, db: db as never });
expect(sendPickAnnouncementNotification).toHaveBeenCalledWith(
expect.objectContaining({ nextOwnerDiscordId: "discord-999" })
);
});
it("omits nextOwnerDiscordId when owner has discordPingEnabled: false", async () => {
const db = makeMockDb({ owner: makeOwner(NEXT_OWNER_ID, false) });
await notifyPickMadeOnDiscord({ ...BASE_PARAMS, db: db as never });
expect(findDiscordIdsByUserIds).not.toHaveBeenCalled();
expect(sendPickAnnouncementNotification).toHaveBeenCalledWith(
expect.objectContaining({ nextOwnerDiscordId: undefined })
);
});
it("omits nextTeamName when next team has no owner", async () => {
const db = makeMockDb({ nextTeam: makeTeam(NEXT_TEAM_ID, null) });
await notifyPickMadeOnDiscord({ ...BASE_PARAMS, db: db as never });
expect(sendPickAnnouncementNotification).toHaveBeenCalledWith(
expect.objectContaining({ nextTeamName: "Beta United", nextOwnerDiscordId: undefined })
);
});
it("skips next-team lookup and passes isDraftComplete: true when draft is done", async () => {
const db = makeMockDb();
await notifyPickMadeOnDiscord({ ...BASE_PARAMS, isDraftComplete: true, db: db as never });
expect(db.query.seasons.findFirst).not.toHaveBeenCalled();
expect(sendPickAnnouncementNotification).toHaveBeenCalledWith(
expect.objectContaining({ isDraftComplete: true, nextTeamName: undefined })
);
});
it("suppresses errors thrown by sendPickAnnouncementNotification — caller's .catch handles them", async () => {
vi.mocked(sendPickAnnouncementNotification).mockRejectedValue(new Error("webhook down"));
const db = makeMockDb();
// The function itself does NOT swallow errors; the caller chains .catch().
await expect(
notifyPickMadeOnDiscord({ ...BASE_PARAMS, db: db as never })
).rejects.toThrow("webhook down");
});
});

View file

@ -190,6 +190,64 @@ export async function sendStandingsUpdateNotification({
await sendDiscordWebhook(webhookUrl, payload); 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)})`,
];
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({ export async function sendDraftOrderNotification({
webhookUrl, webhookUrl,
leagueName, leagueName,
@ -208,7 +266,7 @@ export async function sendDraftOrderNotification({
? `Draft Order Manually Set — ${leagueName}` ? `Draft Order Manually Set — ${leagueName}`
: `Draft Order Randomized — ${leagueName}`; : `Draft Order Randomized — ${leagueName}`;
const sorted = [...teams].sort((a, b) => a.position - b.position); const sorted = teams.toSorted((a, b) => a.position - b.position);
let description = sorted let description = sorted
.map((t) => { .map((t) => {
const teamLabel = escapeMarkdown(t.name); const teamLabel = escapeMarkdown(t.name);

View file

@ -0,0 +1,101 @@
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";
type DraftSlot = { teamId: string; draftOrder: number };
// Inline snake-draft pick calculation — mirrors calculatePickInfo in draft-utils.ts
// without creating a circular import (draft-utils imports this module).
function pickInRoundFor(pickNumber: number, teamCount: number): number {
const round = Math.ceil(pickNumber / teamCount);
const rawPickInRound = ((pickNumber - 1) % teamCount) + 1;
const isOddRound = round % 2 === 1;
const teamIndex = isOddRound ? rawPickInRound - 1 : teamCount - rawPickInRound;
return teamIndex + 1;
}
export async function notifyPickMadeOnDiscord(params: {
seasonId: string;
leagueId: string;
pickedTeamName: string;
participantName: string;
sportName: string;
pickNumber: number;
round: number;
pickInRound: number;
isDraftComplete: boolean;
totalTeams: number;
draftSlots: DraftSlot[];
db: ReturnType<typeof database>;
}): Promise<void> {
const {
seasonId,
leagueId,
pickedTeamName,
participantName,
sportName,
pickNumber,
round,
pickInRound,
isDraftComplete,
totalTeams,
draftSlots,
db,
} = params;
const league = await db.query.leagues.findFirst({
where: eq(schema.leagues.id, leagueId),
});
if (!league?.discordWebhookUrl || !league.discordPicksAnnouncementEnabled) return;
const appUrl = process.env.APP_URL ?? "https://brackt.com";
const draftUrl = `${appUrl}/leagues/${leagueId}/draft/${seasonId}`;
let nextTeamName: string | undefined;
let nextOwnerDiscordId: string | undefined;
if (!isDraftComplete) {
// Read currentPickNumber fresh from DB so we see the post-autodraft-chain state,
// matching the same guarantee sendOnTheClockEmail provides.
const freshSeason = await db.query.seasons.findFirst({
where: eq(schema.seasons.id, seasonId),
});
if (freshSeason) {
const nextPickInRound = pickInRoundFor(freshSeason.currentPickNumber ?? 1, totalTeams);
const nextSlot = draftSlots.find((s) => s.draftOrder === nextPickInRound);
if (nextSlot) {
const nextTeam = await db.query.teams.findFirst({
where: eq(schema.teams.id, nextSlot.teamId),
});
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,
});
}

View file

@ -181,6 +181,7 @@ export const leagues = pgTable("leagues", {
currentSeasonId: uuid("current_season_id"), // References the active season currentSeasonId: uuid("current_season_id"), // References the active season
isPublicDraftBoard: boolean("is_public_draft_board").notNull().default(false), isPublicDraftBoard: boolean("is_public_draft_board").notNull().default(false),
discordWebhookUrl: text("discord_webhook_url"), discordWebhookUrl: text("discord_webhook_url"),
discordPicksAnnouncementEnabled: boolean("discord_picks_announcement_enabled").notNull().default(false),
createdAt: timestamp("created_at").defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_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, "when": 1779312132517,
"tag": "0111_gorgeous_captain_universe", "tag": "0111_gorgeous_captain_universe",
"breakpoints": true "breakpoints": true
},
{
"idx": 112,
"version": "7",
"when": 1779318579169,
"tag": "0112_married_captain_midlands",
"breakpoints": true
} }
] ]
} }