claude/discord-pick-notification-refactor-FeusJ (#58)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 1m36s
🚀 Deploy / ʦ TypeScript (push) Successful in 1m23s
🚀 Deploy / 🔍 Lint (push) Successful in 50s
🚀 Deploy / 🐳 Build (push) Successful in 14m19s
🚀 Deploy / 🚀 Deploy (push) Successful in 13s

Co-authored-by: Claude <noreply@anthropic.com>
Reviewed-on: #58
This commit is contained in:
chrisp 2026-05-29 16:24:22 +00:00
parent 4a2f542fb1
commit ef29c1fcd9
6 changed files with 75 additions and 141 deletions

View file

@ -834,6 +834,8 @@ export async function executeAutoPick(params: {
if (!pickedSlot) { if (!pickedSlot) {
logger.warn(`[AutoPick] Skipping Discord pick announcement: no draft slot found for team ${teamId}`); logger.warn(`[AutoPick] Skipping Discord pick announcement: no draft slot found for team ${teamId}`);
} else { } else {
const { pickInRound: nextPickInRound } = calculatePickInfo(nextPickNumber, totalTeams);
const nextSlot = !isDraftComplete ? draftSlots.find((s) => s.draftOrder === nextPickInRound) : undefined;
enqueuePickNotification(season.leagueId, () => enqueuePickNotification(season.leagueId, () =>
notifyPickMadeOnDiscord({ notifyPickMadeOnDiscord({
seasonId, seasonId,
@ -842,12 +844,11 @@ export async function executeAutoPick(params: {
participantName: participantToPick.name, participantName: participantToPick.name,
sportName: participantToPick.sportsSeason.sport.name, sportName: participantToPick.sportsSeason.sport.name,
pickNumber, pickNumber,
nextPickNumber,
round: currentRound, round: currentRound,
rawPickInRound, pickInRound: rawPickInRound,
isDraftComplete, isDraftComplete,
totalTeams, nextTeamName: nextSlot?.team.name,
draftSlots: draftSlots.map((s) => ({ teamId: s.teamId, draftOrder: s.draftOrder })), nextTeamOwnerId: nextSlot?.team.ownerId,
db, db,
}).catch((err) => logger.error("[AutoPick] Discord pick announcement failed:", err)) }).catch((err) => logger.error("[AutoPick] Discord pick announcement failed:", err))
); );

View file

@ -8,6 +8,9 @@ import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/d
import { getParticipantsForSeasonWithSports } from "~/models/season-participant"; import { getParticipantsForSeasonWithSports } from "~/models/season-participant";
import { getSeasonSportsSimple } from "~/models/season-sport"; import { getSeasonSportsSimple } from "~/models/season-sport";
import { calculatePickInfo, checkAndTriggerNextAutodraft } from "~/models/draft-utils"; import { calculatePickInfo, checkAndTriggerNextAutodraft } from "~/models/draft-utils";
import { getTeamForPick } from "~/lib/draft-order";
import { enqueuePickNotification } from "~/services/discord";
import { notifyPickMadeOnDiscord } from "~/services/draft-discord.server";
import { logCommissionerAction } from "~/models/audit-log"; import { logCommissionerAction } from "~/models/audit-log";
import { getSocketIO, scheduleDraftRoomClosure } from "../../../server/socket"; import { getSocketIO, scheduleDraftRoomClosure } from "../../../server/socket";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
@ -113,7 +116,7 @@ export async function action(args: ActionFunctionArgs) {
}); });
const totalTeams = draftSlots.length; const totalTeams = draftSlots.length;
const { round: currentRound, pickInRound } = calculatePickInfo(pickNumber, totalTeams); const { round: currentRound, pickInRound, rawPickInRound } = calculatePickInfo(pickNumber, totalTeams);
// Validate that the submitted teamId is actually the team whose turn it is at pickNumber // Validate that the submitted teamId is actually the team whose turn it is at pickNumber
const expectedDraftSlot = draftSlots.find((slot) => slot.draftOrder === pickInRound); const expectedDraftSlot = draftSlots.find((slot) => slot.draftOrder === pickInRound);
@ -238,7 +241,7 @@ export async function action(args: ActionFunctionArgs) {
// Emit socket event // Emit socket event
try { try {
const io = getSocketIO(); const io = getSocketIO();
const team = draftSlots.find((slot) => slot.team.id === teamId)?.team; const team = expectedDraftSlot.team;
io.to(`draft-${seasonId}`).emit("pick-made", { io.to(`draft-${seasonId}`).emit("pick-made", {
pick: { pick: {
@ -262,7 +265,24 @@ export async function action(args: ActionFunctionArgs) {
logger.error("Socket.IO error:", error); logger.error("Socket.IO error:", error);
} }
const pickedTeam = draftSlots.find((slot) => slot.team.id === teamId)?.team; // Announce pick on Discord
const nextSlot = !isDraftComplete ? getTeamForPick(nextPickNumber, draftSlots) : undefined;
enqueuePickNotification(season.leagueId, () =>
notifyPickMadeOnDiscord({
seasonId,
leagueId: season.leagueId,
pickedTeamName: expectedDraftSlot.team.name,
participantName: participant.name,
sportName: participant.sportsSeason.sport.name,
pickNumber,
round: currentRound,
pickInRound: rawPickInRound,
isDraftComplete,
nextTeamName: nextSlot?.team.name,
nextTeamOwnerId: nextSlot?.team.ownerId,
db,
}).catch((err) => logger.error("Discord pick announcement failed:", err))
);
await logCommissionerAction({ await logCommissionerAction({
seasonId, seasonId,
@ -273,7 +293,7 @@ export async function action(args: ActionFunctionArgs) {
details: { details: {
pickNumber, pickNumber,
teamId, teamId,
teamName: pickedTeam?.name ?? teamId, teamName: expectedDraftSlot.team.name,
participantId, participantId,
participantName: participant.name, participantName: participant.name,
}, },

View file

@ -1,4 +1,5 @@
import { auth } from "~/lib/auth.server"; import { auth } from "~/lib/auth.server";
import { getTeamForPick } from "~/lib/draft-order";
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq, and, sql } from "drizzle-orm"; import { eq, and, sql } from "drizzle-orm";
@ -306,6 +307,7 @@ export async function action(args: ActionFunctionArgs) {
// Announce before triggering the chain so Discord messages arrive in pick-number // Announce before triggering the chain so Discord messages arrive in pick-number
// order. If the chain ran first, chained picks would announce before this one. // order. If the chain ran first, chained picks would announce before this one.
const nextSlot = !isDraftComplete ? getTeamForPick(nextPickNumber, draftSlots) : undefined;
enqueuePickNotification(season.leagueId, () => enqueuePickNotification(season.leagueId, () =>
notifyPickMadeOnDiscord({ notifyPickMadeOnDiscord({
seasonId, seasonId,
@ -314,12 +316,11 @@ export async function action(args: ActionFunctionArgs) {
participantName: participant.name, participantName: participant.name,
sportName: participant.sportsSeason.sport.name, sportName: participant.sportsSeason.sport.name,
pickNumber: currentPickNumber, pickNumber: currentPickNumber,
nextPickNumber,
round: currentRound, round: currentRound,
rawPickInRound, pickInRound: rawPickInRound,
isDraftComplete, isDraftComplete,
totalTeams, nextTeamName: nextSlot?.team.name,
draftSlots: draftSlots.map((s) => ({ teamId: s.teamId, draftOrder: s.draftOrder })), nextTeamOwnerId: nextSlot?.team.ownerId,
db, db,
}).catch((err) => logger.error("Discord pick announcement failed:", err)) }).catch((err) => logger.error("Discord pick announcement failed:", err))
); );

View file

@ -22,17 +22,9 @@ import { findDiscordIdsByUserIds } from "~/models/account";
const SEASON_ID = "season-1"; const SEASON_ID = "season-1";
const LEAGUE_ID = "league-1"; const LEAGUE_ID = "league-1";
const TEAM_ID = "team-1";
const NEXT_TEAM_ID = "team-2";
const NEXT_OWNER_ID = "owner-2"; const NEXT_OWNER_ID = "owner-2";
const WEBHOOK_URL = "https://discord.com/api/webhooks/123/abc"; 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 = { const BASE_PARAMS = {
seasonId: SEASON_ID, seasonId: SEASON_ID,
leagueId: LEAGUE_ID, leagueId: LEAGUE_ID,
@ -40,12 +32,11 @@ const BASE_PARAMS = {
participantName: "Erling Haaland", participantName: "Erling Haaland",
sportName: "Soccer", sportName: "Soccer",
pickNumber: 1, pickNumber: 1,
nextPickNumber: 2,
round: 1, round: 1,
rawPickInRound: 1, pickInRound: 1,
isDraftComplete: false, isDraftComplete: false,
totalTeams: 2, nextTeamName: "Beta United",
draftSlots: DRAFT_SLOTS, nextTeamOwnerId: NEXT_OWNER_ID,
}; };
function makeLeague(overrides: object = {}) { function makeLeague(overrides: object = {}) {
@ -57,27 +48,20 @@ function makeLeague(overrides: object = {}) {
}; };
} }
function makeTeam(id: string, ownerId: string | null = null, name = "Beta United") {
return { id, name, ownerId };
}
function makeOwner(id: string, discordPingEnabled = true) { function makeOwner(id: string, discordPingEnabled = true) {
return { id, discordPingEnabled }; return { id, discordPingEnabled };
} }
function makeMockDb(overrides: { function makeMockDb(overrides: {
league?: object | null; league?: object | null;
nextTeam?: object | null;
owner?: object | null; owner?: object | null;
} = {}) { } = {}) {
const league = "league" in overrides ? overrides.league : makeLeague(); const league = "league" in overrides ? overrides.league : makeLeague();
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); const owner = "owner" in overrides ? overrides.owner : makeOwner(NEXT_OWNER_ID);
return { return {
query: { query: {
leagues: { findFirst: vi.fn().mockResolvedValue(league) }, leagues: { findFirst: vi.fn().mockResolvedValue(league) },
teams: { findFirst: vi.fn().mockResolvedValue(nextTeam) },
users: { findFirst: vi.fn().mockResolvedValue(owner) }, users: { findFirst: vi.fn().mockResolvedValue(owner) },
}, },
}; };
@ -153,48 +137,29 @@ describe("notifyPickMadeOnDiscord", () => {
); );
}); });
it("passes nextTeamName based on caller-supplied nextPickNumber", async () => { it("forwards caller-supplied nextTeamName to the notification", async () => {
// pick 3 in a 2-team snake: round 2 (reversed) → draftOrder 2 → NEXT_TEAM_ID → "Beta United"
const db = makeMockDb(); const db = makeMockDb();
await notifyPickMadeOnDiscord({ ...BASE_PARAMS, nextPickNumber: 3, db: db as never }); await notifyPickMadeOnDiscord({ ...BASE_PARAMS, nextTeamName: "Beta United", db: db as never });
expect(sendPickAnnouncementNotification).toHaveBeenCalledWith( expect(sendPickAnnouncementNotification).toHaveBeenCalledWith(
expect.objectContaining({ nextTeamName: "Beta United" }) expect.objectContaining({ nextTeamName: "Beta United" })
); );
}); });
it("uses caller-supplied nextPickNumber instead of reading from DB", async () => { it("does not query the teams table (team resolution is the caller's responsibility)", async () => {
const db = makeMockDb(); const db = makeMockDb();
await notifyPickMadeOnDiscord({ ...BASE_PARAMS, db: db as never }); await notifyPickMadeOnDiscord({ ...BASE_PARAMS, db: db as never });
expect((db.query as Record<string, unknown>).seasons).toBeUndefined(); expect((db.query as Record<string, unknown>).teams).toBeUndefined();
}); });
it("shows the immediate next drafter, not the post-autodraft-chain drafter", async () => { it("forwards caller-supplied nextTeamName regardless of pick number", async () => {
// Regression: manual pick #1 by alpha; beta immediately autodrafts (#2); DB advances to pick #3. // Callers must pass the correct nextTeamName; this service just forwards it.
// Discord for pick #1 must show beta ("On the clock: beta"), not gamma. const db = makeMockDb();
// Uses a 3-team snake: pick 1→alpha, pick 2→beta, pick 3→gamma.
const ALPHA_ID = "team-alpha";
const BETA_ID = "team-beta";
const GAMMA_ID = "team-gamma";
const threeTeamSlots = [
{ teamId: ALPHA_ID, draftOrder: 1 },
{ teamId: BETA_ID, draftOrder: 2 },
{ teamId: GAMMA_ID, draftOrder: 3 },
];
const betaTeam = { id: BETA_ID, name: "Beta Squad", ownerId: null };
const db = makeMockDb({ nextTeam: betaTeam });
await notifyPickMadeOnDiscord({ await notifyPickMadeOnDiscord({ ...BASE_PARAMS, nextTeamName: "Beta Squad", db: db as never });
...BASE_PARAMS,
pickNumber: 1,
nextPickNumber: 2, // immediately after alpha's pick — before beta's autodraft
totalTeams: 3,
draftSlots: threeTeamSlots,
db: db as never,
});
expect(sendPickAnnouncementNotification).toHaveBeenCalledWith( expect(sendPickAnnouncementNotification).toHaveBeenCalledWith(
expect.objectContaining({ nextTeamName: "Beta Squad" }) expect.objectContaining({ nextTeamName: "Beta Squad" })
@ -223,24 +188,25 @@ describe("notifyPickMadeOnDiscord", () => {
); );
}); });
it("omits nextTeamName when next team has no owner", async () => { it("omits nextOwnerDiscordId when nextTeamOwnerId is null", async () => {
const db = makeMockDb({ nextTeam: makeTeam(NEXT_TEAM_ID, null) }); const db = makeMockDb();
await notifyPickMadeOnDiscord({ ...BASE_PARAMS, db: db as never }); await notifyPickMadeOnDiscord({ ...BASE_PARAMS, nextTeamOwnerId: null, db: db as never });
expect(findDiscordIdsByUserIds).not.toHaveBeenCalled();
expect(sendPickAnnouncementNotification).toHaveBeenCalledWith( expect(sendPickAnnouncementNotification).toHaveBeenCalledWith(
expect.objectContaining({ nextTeamName: "Beta United", nextOwnerDiscordId: undefined }) expect.objectContaining({ nextTeamName: "Beta United", nextOwnerDiscordId: undefined })
); );
}); });
it("skips next-team lookup and passes isDraftComplete: true when draft is done", async () => { it("skips owner lookup and passes isDraftComplete: true when draft is done", async () => {
const db = makeMockDb(); const db = makeMockDb();
await notifyPickMadeOnDiscord({ ...BASE_PARAMS, isDraftComplete: true, db: db as never }); await notifyPickMadeOnDiscord({ ...BASE_PARAMS, isDraftComplete: true, db: db as never });
expect(db.query.teams.findFirst).not.toHaveBeenCalled(); expect(db.query.users.findFirst).not.toHaveBeenCalled();
expect(sendPickAnnouncementNotification).toHaveBeenCalledWith( expect(sendPickAnnouncementNotification).toHaveBeenCalledWith(
expect.objectContaining({ isDraftComplete: true, nextTeamName: undefined }) expect.objectContaining({ isDraftComplete: true })
); );
}); });
@ -263,8 +229,7 @@ describe("notifyPickMadeOnDiscord", () => {
...BASE_PARAMS, ...BASE_PARAMS,
pickNumber: 22, pickNumber: 22,
round: 2, round: 2,
rawPickInRound: 9, pickInRound: 9,
totalTeams: 13,
db: db as never, db: db as never,
}); });

View file

@ -14,42 +14,17 @@ interface DiscordWebhookPayload {
allowed_mentions?: { parse: string[]; users: string[] }; allowed_mentions?: { parse: string[]; users: string[] };
} }
// Per-league serial queue: serializes pick notifications within a league to // Per-league serial promise chain: serializes pick notifications within a league to
// respect Discord's per-webhook rate limit (~5 req/2s) during autodraft chains. // respect Discord's per-webhook rate limit (~5 req/2s) during autodraft chains.
// Keyed by leagueId so concurrent drafts in different leagues don't block each other. const leagueChains = new Map<string, Promise<void>>();
const leagueQueues = new Map<string, Array<() => Promise<void>>>();
const drainingLeagues = new Set<string>();
async function drainLeagueQueue(leagueId: string) {
if (drainingLeagues.has(leagueId)) return;
drainingLeagues.add(leagueId);
try {
for (;;) {
const queue = leagueQueues.get(leagueId);
if (!queue || queue.length === 0) break;
const task = queue.shift();
if (!task) break;
try {
await task();
} catch {
// task already .catch()es its own errors; this is a safety net so one
// bad task doesn't prevent the rest of the queue from draining
}
}
} finally {
drainingLeagues.delete(leagueId);
leagueQueues.delete(leagueId);
}
}
export function enqueuePickNotification(leagueId: string, fn: () => Promise<void>): void { export function enqueuePickNotification(leagueId: string, fn: () => Promise<void>): void {
let queue = leagueQueues.get(leagueId); const prev = leagueChains.get(leagueId) ?? Promise.resolve();
if (!queue) { const next = prev.then(() => fn().catch(() => {}));
queue = []; leagueChains.set(leagueId, next);
leagueQueues.set(leagueId, queue); // Clean up the map entry once the chain settles, but only if no newer pick
} // was enqueued after this one (reference equality guards against that race).
queue.push(fn); next.then(() => { if (leagueChains.get(leagueId) === next) leagueChains.delete(leagueId); });
drainLeagueQueue(leagueId).catch(() => {});
} }
export async function sendDiscordWebhook( export async function sendDiscordWebhook(

View file

@ -4,18 +4,6 @@ import type { database } from "~/database/context";
import { findDiscordIdsByUserIds } from "~/models/account"; import { findDiscordIdsByUserIds } from "~/models/account";
import { sendPickAnnouncementNotification } from "~/services/discord"; 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: { export async function notifyPickMadeOnDiscord(params: {
seasonId: string; seasonId: string;
leagueId: string; leagueId: string;
@ -23,12 +11,11 @@ export async function notifyPickMadeOnDiscord(params: {
participantName: string; participantName: string;
sportName: string; sportName: string;
pickNumber: number; pickNumber: number;
nextPickNumber: number;
round: number; round: number;
rawPickInRound: number; pickInRound: number;
isDraftComplete: boolean; isDraftComplete: boolean;
totalTeams: number; nextTeamName?: string;
draftSlots: DraftSlot[]; nextTeamOwnerId?: string | null;
db: ReturnType<typeof database>; db: ReturnType<typeof database>;
}): Promise<void> { }): Promise<void> {
const { const {
@ -38,12 +25,11 @@ export async function notifyPickMadeOnDiscord(params: {
participantName, participantName,
sportName, sportName,
pickNumber, pickNumber,
nextPickNumber,
round, round,
rawPickInRound, pickInRound,
isDraftComplete, isDraftComplete,
totalTeams, nextTeamName,
draftSlots, nextTeamOwnerId,
db, db,
} = params; } = params;
@ -55,28 +41,14 @@ export async function notifyPickMadeOnDiscord(params: {
const appUrl = process.env.APP_URL ?? "https://brackt.com"; const appUrl = process.env.APP_URL ?? "https://brackt.com";
const draftUrl = `${appUrl}/leagues/${leagueId}/draft/${seasonId}`; const draftUrl = `${appUrl}/leagues/${leagueId}/draft/${seasonId}`;
let nextTeamName: string | undefined;
let nextOwnerDiscordId: string | undefined; let nextOwnerDiscordId: string | undefined;
if (!isDraftComplete && nextTeamOwnerId) {
if (!isDraftComplete) { const owner = await db.query.users.findFirst({
const nextPickInRound = pickInRoundFor(nextPickNumber, totalTeams); where: eq(schema.users.id, nextTeamOwnerId),
const nextSlot = draftSlots.find((s) => s.draftOrder === nextPickInRound); });
if (nextSlot) { if (owner?.discordPingEnabled) {
const nextTeam = await db.query.teams.findFirst({ const discordIds = await findDiscordIdsByUserIds([owner.id]);
where: eq(schema.teams.id, nextSlot.teamId), nextOwnerDiscordId = discordIds.get(owner.id);
});
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);
}
}
}
} }
} }
@ -85,7 +57,7 @@ export async function notifyPickMadeOnDiscord(params: {
draftUrl, draftUrl,
pickNumber, pickNumber,
round, round,
pickInRound: rawPickInRound, pickInRound,
pickedTeamName, pickedTeamName,
participantName, participantName,
sportName, sportName,