Add Discord notifications for draft order changes (#441)

* Add Discord notification when draft order is set or randomized

Posts the full draft order to the league's Discord webhook after each
set-draft-order or randomize-draft-order action, with distinct titles
(📋 vs 🎲) so members can tell how it was determined.

https://claude.ai/code/session_01XrBxu358RK6q8iDgRQbMFx

* Refine Discord draft order notification: league name/link, usernames, polish

- Replace seasonName with leagueName + leagueUrl; title links to the league
- Distinguish manual vs randomized in title ("Manually Set" vs "Randomized")
- Show owner username next to each team name in the draft order list
- Remove footer in favour of the linked embed title
- Add 4096-char description clamp consistent with standings notification
- Use a teamMap in both intent branches to avoid O(n²) find-in-loop
- Add test for all-unowned-teams rendering no trailing parentheses

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Remove emoji from draft order notification titles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-05-17 23:20:51 -07:00 committed by GitHub
parent 1f0fcf970b
commit c71eb69648
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 246 additions and 4 deletions

View file

@ -23,7 +23,7 @@ import { claimTeam, deleteTeam, findTeamById, findTeamsBySeasonId, removeTeamOwn
import { findUserById, findUsersByIds, getUserDisplayName, isUserAdmin } from "~/models/user";
import { logCommissionerAction } from "~/models/audit-log";
import { parseDraftSpeed } from "~/lib/draft-timer";
import { sendStandingsUpdateNotification } from "~/services/discord";
import { sendDraftOrderNotification, sendStandingsUpdateNotification } from "~/services/discord";
import {
applyBracktSportsForSeason,
seedOrRepairBracktTemplate,
@ -294,6 +294,8 @@ export async function action(args: Route.ActionArgs) {
await setDraftOrder(season.id, teamIds);
const teamMap = new Map(teams.map((t) => [t.id, t]));
await logCommissionerAction({
seasonId: season.id,
leagueId,
@ -303,12 +305,37 @@ export async function action(args: Route.ActionArgs) {
details: {
order: teamIds.map((id, i) => ({
teamId: id,
teamName: teams.find((t) => t.id === id)?.name ?? id,
teamName: teamMap.get(id)?.name ?? id,
position: i + 1,
})),
},
});
if (league.discordWebhookUrl) {
try {
const ownerIds = teams.map((t) => t.ownerId).filter(Boolean) as string[];
const owners = ownerIds.length > 0 ? await findUsersByIds(ownerIds) : [];
const ownerMap = new Map(owners.map((u) => [u.id, getUserDisplayName(u) ?? undefined]));
const appUrl = process.env.APP_URL ?? "https://brackt.com";
await sendDraftOrderNotification({
webhookUrl: league.discordWebhookUrl,
leagueName: league.name,
leagueUrl: `${appUrl}/leagues/${leagueId}`,
method: "manual",
teams: teamIds.map((id, i) => {
const team = teamMap.get(id);
return {
name: team?.name ?? id,
position: i + 1,
username: team?.ownerId ? ownerMap.get(team.ownerId) : undefined,
};
}),
});
} catch (err) {
logger.error("Discord draft order notification failed:", err);
}
}
return { success: true, message: "Draft order updated successfully", section: "draft-order" as const };
}
@ -325,6 +352,8 @@ export async function action(args: Route.ActionArgs) {
const newSlots = await findDraftSlotsBySeasonId(season.id);
const sortedSlots = newSlots.toSorted((a, b) => a.draftOrder - b.draftOrder);
const teamMap = new Map(teams.map((t) => [t.id, t]));
await logCommissionerAction({
seasonId: season.id,
leagueId,
@ -334,12 +363,37 @@ export async function action(args: Route.ActionArgs) {
details: {
order: sortedSlots.map((s) => ({
teamId: s.teamId,
teamName: teams.find((t) => t.id === s.teamId)?.name ?? s.teamId,
teamName: teamMap.get(s.teamId)?.name ?? s.teamId,
position: s.draftOrder,
})),
},
});
if (league.discordWebhookUrl) {
try {
const ownerIds = teams.map((t) => t.ownerId).filter(Boolean) as string[];
const owners = ownerIds.length > 0 ? await findUsersByIds(ownerIds) : [];
const ownerMap = new Map(owners.map((u) => [u.id, getUserDisplayName(u) ?? undefined]));
const appUrl = process.env.APP_URL ?? "https://brackt.com";
await sendDraftOrderNotification({
webhookUrl: league.discordWebhookUrl,
leagueName: league.name,
leagueUrl: `${appUrl}/leagues/${leagueId}`,
method: "randomized",
teams: sortedSlots.map((s) => {
const team = teamMap.get(s.teamId);
return {
name: team?.name ?? s.teamId,
position: s.draftOrder,
username: team?.ownerId ? ownerMap.get(team.ownerId) : undefined,
};
}),
});
} catch (err) {
logger.error("Discord draft order notification failed:", err);
}
}
return { success: true, message: "Draft order randomized successfully", section: "draft-order" as const };
}

View file

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { sendDiscordWebhook, sendStandingsUpdateNotification } from "../discord";
import { sendDiscordWebhook, sendStandingsUpdateNotification, sendDraftOrderNotification } from "../discord";
const WEBHOOK_URL = "https://discord.com/api/webhooks/123/abc";
@ -442,3 +442,148 @@ describe("sendStandingsUpdateNotification", () => {
expect(desc).not.toContain("Beta");
});
});
describe("sendDraftOrderNotification", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch(204));
});
it("uses '📋 Draft Order Manually Set' title for manual method", async () => {
await sendDraftOrderNotification({
webhookUrl: WEBHOOK_URL,
leagueName: "My League",
leagueUrl: "https://brackt.com/leagues/abc",
method: "manual",
teams: [{ name: "Team Alpha", position: 1 }],
});
const payload = getPayload();
expect(payload.embeds[0].title).toBe("Draft Order Manually Set — My League");
});
it("uses '🎲 Draft Order Randomized' title for randomized method", async () => {
await sendDraftOrderNotification({
webhookUrl: WEBHOOK_URL,
leagueName: "My League",
leagueUrl: "https://brackt.com/leagues/abc",
method: "randomized",
teams: [{ name: "Team Alpha", position: 1 }],
});
const payload = getPayload();
expect(payload.embeds[0].title).toBe("Draft Order Randomized — My League");
});
it("links the embed title to the league URL", async () => {
await sendDraftOrderNotification({
webhookUrl: WEBHOOK_URL,
leagueName: "My League",
leagueUrl: "https://brackt.com/leagues/abc",
method: "manual",
teams: [{ name: "Team Alpha", position: 1 }],
});
const payload = getPayload();
expect(payload.embeds[0].url).toBe("https://brackt.com/leagues/abc");
});
it("renders a numbered list of teams sorted by position", async () => {
await sendDraftOrderNotification({
webhookUrl: WEBHOOK_URL,
leagueName: "My League",
leagueUrl: "https://brackt.com/leagues/abc",
method: "manual",
teams: [
{ name: "Team Gamma", position: 3 },
{ name: "Team Alpha", position: 1 },
{ name: "Team Beta", position: 2 },
],
});
const payload = getPayload();
expect(payload.embeds[0].description).toBe(
"1. Team Alpha\n2. Team Beta\n3. Team Gamma"
);
});
it("shows username in parentheses next to team name when provided", async () => {
await sendDraftOrderNotification({
webhookUrl: WEBHOOK_URL,
leagueName: "My League",
leagueUrl: "https://brackt.com/leagues/abc",
method: "manual",
teams: [
{ name: "Team Alpha", position: 1, username: "chris" },
{ name: "Team Beta", position: 2 },
],
});
const payload = getPayload();
expect(payload.embeds[0].description).toContain("1. Team Alpha (chris)");
expect(payload.embeds[0].description).toContain("2. Team Beta");
expect(payload.embeds[0].description).not.toContain("2. Team Beta (");
});
it("escapes Discord markdown in team names and usernames", async () => {
await sendDraftOrderNotification({
webhookUrl: WEBHOOK_URL,
leagueName: "My League",
leagueUrl: "https://brackt.com/leagues/abc",
method: "manual",
teams: [
{ name: "Team__Underline", position: 1, username: "user__name" },
{ name: "Team*Bold*", position: 2 },
],
});
const payload = getPayload();
expect(payload.embeds[0].description).toContain("Team\\_\\_Underline");
expect(payload.embeds[0].description).toContain("user\\_\\_name");
expect(payload.embeds[0].description).toContain("Team\\*Bold\\*");
});
it("uses color 0x5865f2 and has no footer", async () => {
await sendDraftOrderNotification({
webhookUrl: WEBHOOK_URL,
leagueName: "My League",
leagueUrl: "https://brackt.com/leagues/abc",
method: "manual",
teams: [{ name: "Team Alpha", position: 1 }],
});
const payload = getPayload();
expect(payload.embeds[0].color).toBe(0x5865f2);
expect(payload.embeds[0].footer).toBeUndefined();
});
it("does not set content or allowed_mentions", async () => {
await sendDraftOrderNotification({
webhookUrl: WEBHOOK_URL,
leagueName: "My League",
leagueUrl: "https://brackt.com/leagues/abc",
method: "manual",
teams: [{ name: "Team Alpha", position: 1 }],
});
const payload = getPayload();
expect(payload.content).toBeUndefined();
expect(payload.allowed_mentions).toBeUndefined();
});
it("renders bare team names with no trailing parentheses when no team has an owner", async () => {
await sendDraftOrderNotification({
webhookUrl: WEBHOOK_URL,
leagueName: "My League",
leagueUrl: "https://brackt.com/leagues/abc",
method: "manual",
teams: [
{ name: "Team Alpha", position: 1 },
{ name: "Team Beta", position: 2 },
],
});
const payload = getPayload();
expect(payload.embeds[0].description).toBe("1. Team Alpha\n2. Team Beta");
expect(payload.embeds[0].description).not.toContain("(");
});
});

View file

@ -2,6 +2,7 @@ import { buildTiedRankChecker } from "~/lib/standings-display";
interface DiscordEmbed {
title?: string;
url?: string;
description?: string;
color?: number;
footer?: { text: string };
@ -188,3 +189,45 @@ export async function sendStandingsUpdateNotification({
await sendDiscordWebhook(webhookUrl, payload);
}
export async function sendDraftOrderNotification({
webhookUrl,
leagueName,
leagueUrl,
method,
teams,
}: {
webhookUrl: string;
leagueName: string;
leagueUrl: string;
method: "manual" | "randomized";
teams: Array<{ name: string; position: number; username?: string }>;
}): Promise<void> {
const title =
method === "manual"
? `Draft Order Manually Set — ${leagueName}`
: `Draft Order Randomized — ${leagueName}`;
const sorted = [...teams].sort((a, b) => a.position - b.position);
let description = sorted
.map((t) => {
const teamLabel = escapeMarkdown(t.name);
const usernameLabel = t.username ? ` (${escapeMarkdown(t.username)})` : "";
return `${t.position}. ${teamLabel}${usernameLabel}`;
})
.join("\n");
if (description.length > 4096) {
description = description.slice(0, 4093) + "...";
}
await sendDiscordWebhook(webhookUrl, {
embeds: [
{
title,
url: leagueUrl,
description,
color: 0x5865f2,
},
],
});
}