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:
parent
b8b58ec9f8
commit
ba606d94be
14 changed files with 6548 additions and 14 deletions
|
|
@ -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,39 @@ 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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,27 @@ 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);
|
||||||
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
pick: draftPick,
|
pick: draftPick,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,26 @@ 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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
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({
|
return Response.json({
|
||||||
success: true,
|
success: true,
|
||||||
pick: draftPick,
|
pick: draftPick,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,65 @@ 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)})`,
|
||||||
|
`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({
|
export async function sendDraftOrderNotification({
|
||||||
webhookUrl,
|
webhookUrl,
|
||||||
leagueName,
|
leagueName,
|
||||||
|
|
|
||||||
84
app/services/draft-discord.server.ts
Normal file
84
app/services/draft-discord.server.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
1
drizzle/0112_married_captain_midlands.sql
Normal file
1
drizzle/0112_married_captain_midlands.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE "leagues" ADD COLUMN "discord_picks_announcement_enabled" boolean DEFAULT false NOT NULL;
|
||||||
6171
drizzle/meta/0112_snapshot.json
Normal file
6171
drizzle/meta/0112_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Loading…
Add table
Reference in a new issue