Simplify Discord pick notifications and fix force-manual-pick gap

- Replace 30-line Map+Set queue with a 4-line per-league promise chain in
  discord.ts — picks still arrive in order, errors don't break the chain
- Remove the internal DB re-queries from notifyPickMadeOnDiscord; callers
  now pre-resolve nextTeamName and nextTeamOwnerId from the draftSlots they
  already hold, eliminating the redundant team/owner lookups and the
  pickInRoundFor circular-import workaround
- Add the missing Discord notification to draft.force-manual-pick.ts (was
  the only pick path that never notified Discord)
- Update tests to match the simplified function signature

https://claude.ai/code/session_01GCkguG2muQwnh3WTvENrwJ
This commit is contained in:
Claude 2026-05-29 15:38:09 +00:00
parent 4a2f542fb1
commit ad10bd3f7d
No known key found for this signature in database
6 changed files with 70 additions and 138 deletions

View file

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

View file

@ -8,6 +8,8 @@ import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/d
import { getParticipantsForSeasonWithSports } from "~/models/season-participant";
import { getSeasonSportsSimple } from "~/models/season-sport";
import { calculatePickInfo, checkAndTriggerNextAutodraft } from "~/models/draft-utils";
import { enqueuePickNotification } from "~/services/discord";
import { notifyPickMadeOnDiscord } from "~/services/draft-discord.server";
import { logCommissionerAction } from "~/models/audit-log";
import { getSocketIO, scheduleDraftRoomClosure } from "../../../server/socket";
import { logger } from "~/lib/logger";
@ -113,7 +115,7 @@ export async function action(args: ActionFunctionArgs) {
});
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
const expectedDraftSlot = draftSlots.find((slot) => slot.draftOrder === pickInRound);
@ -264,6 +266,26 @@ export async function action(args: ActionFunctionArgs) {
const pickedTeam = draftSlots.find((slot) => slot.team.id === teamId)?.team;
// Announce pick on Discord
const { pickInRound: nextPickInRound } = calculatePickInfo(nextPickNumber, totalTeams);
const nextSlot = !isDraftComplete ? draftSlots.find((s) => s.draftOrder === nextPickInRound) : undefined;
enqueuePickNotification(season.leagueId, () =>
notifyPickMadeOnDiscord({
seasonId,
leagueId: season.leagueId,
pickedTeamName: pickedTeam?.name ?? teamId,
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({
seasonId,
leagueId: season.leagueId,

View file

@ -306,6 +306,8 @@ export async function action(args: ActionFunctionArgs) {
// 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.
const { pickInRound: nextPickInRound } = calculatePickInfo(nextPickNumber, totalTeams);
const nextSlot = !isDraftComplete ? draftSlots.find((s) => s.draftOrder === nextPickInRound) : undefined;
enqueuePickNotification(season.leagueId, () =>
notifyPickMadeOnDiscord({
seasonId,
@ -314,12 +316,11 @@ export async function action(args: ActionFunctionArgs) {
participantName: participant.name,
sportName: participant.sportsSeason.sport.name,
pickNumber: currentPickNumber,
nextPickNumber,
round: currentRound,
rawPickInRound,
pickInRound: rawPickInRound,
isDraftComplete,
totalTeams,
draftSlots: draftSlots.map((s) => ({ teamId: s.teamId, draftOrder: s.draftOrder })),
nextTeamName: nextSlot?.team.name,
nextTeamOwnerId: nextSlot?.team.ownerId,
db,
}).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 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,
@ -40,12 +32,11 @@ const BASE_PARAMS = {
participantName: "Erling Haaland",
sportName: "Soccer",
pickNumber: 1,
nextPickNumber: 2,
round: 1,
rawPickInRound: 1,
pickInRound: 1,
isDraftComplete: false,
totalTeams: 2,
draftSlots: DRAFT_SLOTS,
nextTeamName: "Beta United",
nextTeamOwnerId: NEXT_OWNER_ID,
};
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) {
return { id, discordPingEnabled };
}
function makeMockDb(overrides: {
league?: object | null;
nextTeam?: object | null;
owner?: object | null;
} = {}) {
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);
return {
query: {
leagues: { findFirst: vi.fn().mockResolvedValue(league) },
teams: { findFirst: vi.fn().mockResolvedValue(nextTeam) },
users: { findFirst: vi.fn().mockResolvedValue(owner) },
},
};
@ -153,48 +137,29 @@ describe("notifyPickMadeOnDiscord", () => {
);
});
it("passes nextTeamName based on caller-supplied nextPickNumber", async () => {
// pick 3 in a 2-team snake: round 2 (reversed) → draftOrder 2 → NEXT_TEAM_ID → "Beta United"
it("forwards caller-supplied nextTeamName to the notification", async () => {
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.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();
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 () => {
// Regression: manual pick #1 by alpha; beta immediately autodrafts (#2); DB advances to pick #3.
// Discord for pick #1 must show beta ("On the clock: beta"), not gamma.
// 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 });
it("forwards caller-supplied nextTeamName regardless of pick number", async () => {
// Callers must pass the correct nextTeamName; this service just forwards it.
const db = makeMockDb();
await notifyPickMadeOnDiscord({
...BASE_PARAMS,
pickNumber: 1,
nextPickNumber: 2, // immediately after alpha's pick — before beta's autodraft
totalTeams: 3,
draftSlots: threeTeamSlots,
db: db as never,
});
await notifyPickMadeOnDiscord({ ...BASE_PARAMS, nextTeamName: "Beta Squad", db: db as never });
expect(sendPickAnnouncementNotification).toHaveBeenCalledWith(
expect.objectContaining({ nextTeamName: "Beta Squad" })
@ -223,24 +188,25 @@ describe("notifyPickMadeOnDiscord", () => {
);
});
it("omits nextTeamName when next team has no owner", async () => {
const db = makeMockDb({ nextTeam: makeTeam(NEXT_TEAM_ID, null) });
it("omits nextOwnerDiscordId when nextTeamOwnerId is null", async () => {
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.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();
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.objectContaining({ isDraftComplete: true, nextTeamName: undefined })
expect.objectContaining({ isDraftComplete: true })
);
});
@ -263,8 +229,7 @@ describe("notifyPickMadeOnDiscord", () => {
...BASE_PARAMS,
pickNumber: 22,
round: 2,
rawPickInRound: 9,
totalTeams: 13,
pickInRound: 9,
db: db as never,
});

View file

@ -14,42 +14,13 @@ interface DiscordWebhookPayload {
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.
// Keyed by leagueId so concurrent drafts in different leagues don't block each other.
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);
}
}
const leagueChains = new Map<string, Promise<void>>();
export function enqueuePickNotification(leagueId: string, fn: () => Promise<void>): void {
let queue = leagueQueues.get(leagueId);
if (!queue) {
queue = [];
leagueQueues.set(leagueId, queue);
}
queue.push(fn);
drainLeagueQueue(leagueId).catch(() => {});
const prev = leagueChains.get(leagueId) ?? Promise.resolve();
leagueChains.set(leagueId, prev.then(() => fn().catch(() => {})));
}
export async function sendDiscordWebhook(

View file

@ -4,18 +4,6 @@ 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;
@ -23,12 +11,11 @@ export async function notifyPickMadeOnDiscord(params: {
participantName: string;
sportName: string;
pickNumber: number;
nextPickNumber: number;
round: number;
rawPickInRound: number;
pickInRound: number;
isDraftComplete: boolean;
totalTeams: number;
draftSlots: DraftSlot[];
nextTeamName?: string;
nextTeamOwnerId?: string | null;
db: ReturnType<typeof database>;
}): Promise<void> {
const {
@ -38,12 +25,11 @@ export async function notifyPickMadeOnDiscord(params: {
participantName,
sportName,
pickNumber,
nextPickNumber,
round,
rawPickInRound,
pickInRound,
isDraftComplete,
totalTeams,
draftSlots,
nextTeamName,
nextTeamOwnerId,
db,
} = params;
@ -55,28 +41,14 @@ export async function notifyPickMadeOnDiscord(params: {
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) {
const nextPickInRound = pickInRoundFor(nextPickNumber, 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);
}
}
}
if (!isDraftComplete && nextTeamOwnerId) {
const owner = await db.query.users.findFirst({
where: eq(schema.users.id, nextTeamOwnerId),
});
if (owner?.discordPingEnabled) {
const discordIds = await findDiscordIdsByUserIds([owner.id]);
nextOwnerDiscordId = discordIds.get(owner.id);
}
}
@ -85,7 +57,7 @@ export async function notifyPickMadeOnDiscord(params: {
draftUrl,
pickNumber,
round,
pickInRound: rawPickInRound,
pickInRound,
pickedTeamName,
participantName,
sportName,