claude/discord-qualifying-points-t55s3x (#121)
Some checks failed
🚀 Deploy / 🐳 Build (push) Failing after 43s
🚀 Deploy / 🚀 Deploy (push) Has been skipped
🚀 Deploy / 🧪 Test (push) Successful in 2m53s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m18s

Co-authored-by: Claude <noreply@anthropic.com>
Reviewed-on: #121
This commit is contained in:
chrisp 2026-07-01 17:29:45 +00:00
parent 64e5cb23ef
commit b0c25beb90
6 changed files with 780 additions and 2 deletions

View file

@ -17,6 +17,8 @@ import { cs2MajorStageResults, seasonParticipants, eventResults } from "~/databa
import { eq, and, sql } from "drizzle-orm";
import { getQPConfig, calculateSplitQualifyingPoints, writeEventResultsQP, recalculateParticipantQP } from "~/models/qualifying-points";
import { deleteEventResults } from "~/models/event-result";
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server";
import { logger } from "~/lib/logger";
export interface Cs2StageResult {
id: string;
@ -438,5 +440,30 @@ export async function assignCs2EliminationQP(
}
}
// Snapshot existing QP before writing so the notification only covers new/changed participants,
// preventing earlier stage exits from being re-announced on each subsequent stage call.
const existingRows = await db.query.eventResults.findMany({
where: eq(eventResults.scoringEventId, scoringEventId),
});
const existingQPBySPId = new Map<string, number>(
existingRows
.filter((r) => r.qualifyingPointsAwarded !== null)
.map((r) => [r.seasonParticipantId, parseFloat(r.qualifyingPointsAwarded as string)])
);
await writeEventResultsQP(scoringEventId, sportsSeasonId, resultsByParticipant, db);
const changedIds = new Set<string>(
[...resultsByParticipant.entries()]
.filter(([id, { qp }]) => existingQPBySPId.get(id) !== qp)
.map(([id]) => id)
);
if (changedIds.size > 0) {
try {
await notifyQualifyingPointsUpdate(sportsSeasonId, scoringEventId, db, changedIds);
} catch (error) {
logger.error(`[CS2MajorStage] QP Discord notification failed for event ${scoringEventId}:`, error);
}
}
}

View file

@ -10,6 +10,7 @@ import {
import { getSeasonResults } from "./participant-season-result";
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
import { sendStandingsUpdateNotification, type ScoredMatch, type EliminatedTeam } from "~/services/discord";
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server";
import { BRACKET_TEMPLATES, type BracketRound } from "~/lib/bracket-templates";
import { doesLoserAdvance, findPlayoffMatchesByEventId } from "~/models/playoff-match";
import { getUserDisplayName } from "~/models/user";
@ -958,6 +959,12 @@ export async function processQualifyingEvent(
logger.log(
`[ScoringCalculator] Processed qualifying event ${eventId}: awarded QP to ${results.length} participants`
);
try {
await notifyQualifyingPointsUpdate(event.sportsSeasonId, eventId, db);
} catch (error) {
logger.error(`[ScoringCalculator] QP Discord notification failed for event ${eventId}:`, error);
}
}
/**

View file

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { sendDiscordWebhook, sendStandingsUpdateNotification, sendDraftOrderNotification, sendPickAnnouncementNotification } from "../discord";
import { sendDiscordWebhook, sendStandingsUpdateNotification, sendDraftOrderNotification, sendPickAnnouncementNotification, sendQualifyingPointsUpdateNotification } from "../discord";
const WEBHOOK_URL = "https://discord.com/api/webhooks/123/abc";
@ -675,6 +675,184 @@ describe("sendDraftOrderNotification", () => {
});
});
describe("sendQualifyingPointsUpdateNotification", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch(204));
});
const BASE_ENTRIES = [
{ participantName: "Novak Djokovic", qpEarned: 20, qpTotal: 45, ownerUsername: "alex" },
{ participantName: "Carlos Alcaraz", qpEarned: 14, qpTotal: 34, ownerUsername: "chris" },
{ participantName: "Rafael Nadal", qpEarned: 0, qpTotal: 20, ownerUsername: "alex" },
];
it("sends an embed with gold color and QP title", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: BASE_ENTRIES,
});
const payload = getPayload();
expect(payload.embeds[0].title).toBe("🏅 Qualifying Points Update — Slam League 2025");
expect(payload.embeds[0].color).toBe(0x5865f2);
expect(payload.embeds[0].footer).toEqual({ text: "brackt.com" });
});
it("shows sport and event header", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
sportName: "ATP Tennis",
eventName: "Wimbledon 2025",
entries: BASE_ENTRIES,
});
const desc = getDescription();
expect(desc).toContain("**ATP Tennis — Wimbledon 2025**");
});
it("shows Points Awarded section for entries with qpEarned > 0, sorted by QP desc", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: BASE_ENTRIES,
});
const desc = getDescription();
expect(desc).toContain("**Points Awarded**");
expect(desc).toContain("• **Novak Djokovic (alex)** — +20 QP");
expect(desc).toContain("• **Carlos Alcaraz (chris)** — +14 QP");
// Djokovic (20 QP) should appear before Alcaraz (14 QP)
expect(desc.indexOf("Novak Djokovic")).toBeLessThan(desc.indexOf("Carlos Alcaraz"));
});
it("shows No Points section for entries with qpEarned == 0", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: BASE_ENTRIES,
});
const desc = getDescription();
expect(desc).toContain("**No Points**");
expect(desc).toContain("Rafael Nadal (alex)");
});
it("shows QP Standings for all entries sorted by qpTotal desc", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: BASE_ENTRIES,
});
const desc = getDescription();
expect(desc).toContain("**QP Standings**");
expect(desc).toContain("1\\. Novak Djokovic (alex) — 45 QP");
expect(desc).toContain("2\\. Carlos Alcaraz (chris) — 34 QP");
expect(desc).toContain("3\\. Rafael Nadal (alex) — 20 QP");
// Djokovic should rank above Alcaraz
expect(desc.indexOf("1\\. Novak")).toBeLessThan(desc.indexOf("2\\. Carlos"));
});
it("uses T-prefix for tied QP totals in standings", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: [
{ participantName: "Player A", qpEarned: 10, qpTotal: 25 },
{ participantName: "Player B", qpEarned: 5, qpTotal: 25 },
{ participantName: "Player C", qpEarned: 2, qpTotal: 10 },
],
});
const desc = getDescription();
expect(desc).toContain("T1\\. Player A");
expect(desc).toContain("T1\\. Player B");
expect(desc).toContain("3\\. Player C");
});
it("uses Discord mention instead of username when ownerDiscordUserId provided", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: [
{
participantName: "Novak Djokovic",
qpEarned: 20,
qpTotal: 45,
ownerUsername: "alex",
ownerDiscordUserId: "111222333",
},
],
});
const desc = getDescription();
expect(desc).toContain("Novak Djokovic (<@111222333>)");
expect(desc).not.toContain("(alex)");
});
it("pings opted-in owners who appear in awarded or no-points sections", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: [
{ participantName: "Player A", qpEarned: 20, qpTotal: 20, ownerDiscordUserId: "111" },
{ participantName: "Player B", qpEarned: 0, qpTotal: 0, ownerDiscordUserId: "222" },
],
});
const payload = getPayload();
expect(payload.content).toContain("<@111>");
expect(payload.content).toContain("<@222>");
expect(payload.allowed_mentions.users).toContain("111");
expect(payload.allowed_mentions.users).toContain("222");
});
it("does not send when entries array is empty", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: [],
});
expect(fetch).not.toHaveBeenCalled();
});
it("escapes markdown in participant names and usernames", async () => {
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: [
{ participantName: "Player_One", qpEarned: 10, qpTotal: 10, ownerUsername: "user_name" },
],
});
const desc = getDescription();
expect(desc).toContain("Player\\_One");
expect(desc).toContain("user\\_name");
});
it("truncates description at 4096 characters", async () => {
const longEntries = Array.from({ length: 200 }, (_, i) => ({
participantName: `Very Long Participant Name Number ${i}`,
qpEarned: i % 2 === 0 ? 5 : 0,
qpTotal: 200 - i,
ownerUsername: `owner_with_long_username_${i}`,
}));
await sendQualifyingPointsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
entries: longEntries,
});
const desc = getDescription();
expect(desc.length).toBeLessThanOrEqual(4096);
expect(desc.endsWith("...")).toBe(true);
});
});
describe("sendPickAnnouncementNotification", () => {
const BASE = {
webhookUrl: WEBHOOK_URL,

View file

@ -0,0 +1,294 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
vi.mock("~/services/discord", () => ({
sendQualifyingPointsUpdateNotification: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("~/models/account", () => ({
findDiscordIdsByUserIds: vi.fn().mockResolvedValue(new Map()),
}));
vi.mock("~/models/user", () => ({
getUserDisplayName: vi.fn((u: { username?: string }) => u.username ?? null),
}));
import { notifyQualifyingPointsUpdate } from "../qualifying-points-discord.server";
import { sendQualifyingPointsUpdateNotification } from "~/services/discord";
import { findDiscordIdsByUserIds } from "~/models/account";
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const SPORTS_SEASON_ID = "ss-1";
const SCORING_EVENT_ID = "ev-1";
const SEASON_ID = "season-1";
const PARTICIPANT_ID = "p-1";
const OWNER_ID = "u-1";
const WEBHOOK_URL = "https://discord.com/api/webhooks/123/abc";
function makeEvent(overrides = {}) {
return {
id: SCORING_EVENT_ID,
name: "Roland Garros 2025",
sportsSeason: { sport: { name: "Tennis" } },
...overrides,
};
}
function makeDb(overrides: Record<string, unknown> = {}) {
return {
query: {
scoringEvents: {
findFirst: vi.fn().mockResolvedValue(makeEvent()),
},
seasonSports: {
findMany: vi.fn().mockResolvedValue([{ seasonId: SEASON_ID }]),
},
eventResults: {
findMany: vi.fn().mockResolvedValue([
{
seasonParticipantId: PARTICIPANT_ID,
qualifyingPointsAwarded: "10",
scoringEventId: SCORING_EVENT_ID,
},
]),
},
seasonParticipantQualifyingTotals: {
findMany: vi.fn().mockResolvedValue([
{ participantId: PARTICIPANT_ID, totalQualifyingPoints: "45", sportsSeasonId: SPORTS_SEASON_ID },
]),
},
seasons: {
findMany: vi.fn().mockResolvedValue([
{
id: SEASON_ID,
year: 2025,
league: { name: "Slam League", discordWebhookUrl: WEBHOOK_URL },
},
]),
},
draftPicks: {
findMany: vi.fn().mockResolvedValue([
{
participantId: PARTICIPANT_ID,
seasonId: SEASON_ID,
team: { id: "t-1", name: "Alpha FC", ownerId: OWNER_ID },
},
]),
},
seasonParticipants: {
findMany: vi.fn().mockResolvedValue([{ id: PARTICIPANT_ID, name: "Carlos Alcaraz" }]),
},
users: {
findMany: vi.fn().mockResolvedValue([
{ id: OWNER_ID, username: "chris", discordPingEnabled: false },
]),
},
...overrides,
},
};
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(findDiscordIdsByUserIds).mockResolvedValue(new Map());
});
describe("notifyQualifyingPointsUpdate", () => {
it("returns early when the scoring event is not found", async () => {
const db = makeDb({
scoringEvents: { findFirst: vi.fn().mockResolvedValue(null) },
});
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
expect(sendQualifyingPointsUpdateNotification).not.toHaveBeenCalled();
});
it("returns early when no season sports exist for the sports season", async () => {
const db = makeDb({
seasonSports: { findMany: vi.fn().mockResolvedValue([]) },
});
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
expect(sendQualifyingPointsUpdateNotification).not.toHaveBeenCalled();
});
it("returns early when no qualifying points were awarded in the event", async () => {
const db = makeDb({
eventResults: {
findMany: vi.fn().mockResolvedValue([
{ seasonParticipantId: PARTICIPANT_ID, qualifyingPointsAwarded: null },
]),
},
});
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
expect(sendQualifyingPointsUpdateNotification).not.toHaveBeenCalled();
});
it("skips leagues without a Discord webhook URL", async () => {
const db = makeDb({
seasons: {
findMany: vi.fn().mockResolvedValue([
{ id: SEASON_ID, year: 2025, league: { name: "Slam League", discordWebhookUrl: null } },
]),
},
});
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
expect(sendQualifyingPointsUpdateNotification).not.toHaveBeenCalled();
});
it("skips leagues where no QP participants were drafted", async () => {
const db = makeDb({
draftPicks: {
findMany: vi.fn().mockResolvedValue([]),
},
});
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
expect(sendQualifyingPointsUpdateNotification).not.toHaveBeenCalled();
});
it("calls sendQualifyingPointsUpdateNotification with correct participant data", async () => {
const db = makeDb();
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
expect(sendQualifyingPointsUpdateNotification).toHaveBeenCalledOnce();
expect(sendQualifyingPointsUpdateNotification).toHaveBeenCalledWith(
expect.objectContaining({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
eventName: "Roland Garros 2025",
sportName: "Tennis",
entries: [
expect.objectContaining({
participantName: "Carlos Alcaraz",
qpEarned: 10,
qpTotal: 45,
ownerUsername: "chris",
ownerDiscordUserId: undefined,
}),
],
})
);
});
it("sends once per league, not once per participant", async () => {
const db = makeDb({
seasonSports: {
findMany: vi.fn().mockResolvedValue([{ seasonId: SEASON_ID }, { seasonId: "season-2" }]),
},
seasons: {
findMany: vi.fn().mockResolvedValue([
{ id: SEASON_ID, year: 2025, league: { name: "League A", discordWebhookUrl: WEBHOOK_URL } },
{ id: "season-2", year: 2025, league: { name: "League B", discordWebhookUrl: "https://discord.com/api/webhooks/456/def" } },
]),
},
draftPicks: {
findMany: vi.fn().mockResolvedValue([
{ participantId: PARTICIPANT_ID, seasonId: SEASON_ID, team: { name: "Alpha FC", ownerId: null } },
{ participantId: PARTICIPANT_ID, seasonId: "season-2", team: { name: "Beta FC", ownerId: null } },
]),
},
});
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
expect(sendQualifyingPointsUpdateNotification).toHaveBeenCalledTimes(2);
});
it("resolves owner Discord ID and passes it when user has discordPingEnabled", async () => {
vi.mocked(findDiscordIdsByUserIds).mockResolvedValue(new Map([[OWNER_ID, "discord-999"]]));
const db = makeDb({
users: {
findMany: vi.fn().mockResolvedValue([
{ id: OWNER_ID, username: "chris", discordPingEnabled: true },
]),
},
});
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
expect(sendQualifyingPointsUpdateNotification).toHaveBeenCalledWith(
expect.objectContaining({
entries: [expect.objectContaining({ ownerDiscordUserId: "discord-999" })],
})
);
});
it("omits ownerDiscordUserId when user has discordPingEnabled false", async () => {
const db = makeDb();
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
expect(findDiscordIdsByUserIds).toHaveBeenCalledWith([]);
expect(sendQualifyingPointsUpdateNotification).toHaveBeenCalledWith(
expect.objectContaining({
entries: [expect.objectContaining({ ownerDiscordUserId: undefined })],
})
);
});
it("participantIdFilter limits notification to only specified participants", async () => {
const db = makeDb({
eventResults: {
findMany: vi.fn().mockResolvedValue([
{ seasonParticipantId: PARTICIPANT_ID, qualifyingPointsAwarded: "10" },
{ seasonParticipantId: "p-2", qualifyingPointsAwarded: "5" },
]),
},
draftPicks: {
findMany: vi.fn().mockResolvedValue([
{ participantId: PARTICIPANT_ID, seasonId: SEASON_ID, team: { name: "Alpha FC", ownerId: null } },
{ participantId: "p-2", seasonId: SEASON_ID, team: { name: "Beta FC", ownerId: null } },
]),
},
seasonParticipants: {
findMany: vi.fn().mockResolvedValue([
{ id: PARTICIPANT_ID, name: "Carlos Alcaraz" },
{ id: "p-2", name: "Rafael Nadal" },
]),
},
});
await notifyQualifyingPointsUpdate(
SPORTS_SEASON_ID,
SCORING_EVENT_ID,
db as never,
new Set([PARTICIPANT_ID])
);
const call = vi.mocked(sendQualifyingPointsUpdateNotification).mock.calls[0][0];
expect(call.entries).toHaveLength(1);
expect(call.entries[0].participantName).toBe("Carlos Alcaraz");
});
it("does not call sendQualifyingPointsUpdateNotification when all participants are filtered out", async () => {
const db = makeDb();
await notifyQualifyingPointsUpdate(
SPORTS_SEASON_ID,
SCORING_EVENT_ID,
db as never,
new Set(["p-nonexistent"])
);
expect(sendQualifyingPointsUpdateNotification).not.toHaveBeenCalled();
});
});

View file

@ -231,7 +231,7 @@ export async function sendStandingsUpdateNotification({
{
title: `📊 Standings Update — ${seasonName}`,
description,
color: 0x5865f2, // Discord blurple
color: 0xffd700, // Gold
footer: { text: "brackt.com" },
},
],
@ -247,6 +247,129 @@ export async function sendStandingsUpdateNotification({
await sendDiscordWebhook(webhookUrl, payload);
}
export interface QPEventEntry {
participantName: string;
qpEarned: number;
qpTotal: number;
ownerUsername?: string;
ownerDiscordUserId?: string;
}
export async function sendQualifyingPointsUpdateNotification({
webhookUrl,
seasonName,
sportName,
eventName,
entries,
}: {
webhookUrl: string;
seasonName: string;
sportName?: string;
eventName?: string;
entries: QPEventEntry[];
}): Promise<void> {
if (entries.length === 0) return;
const sections: string[] = [];
if (sportName || eventName) {
const parts = [sportName, eventName].filter(Boolean);
sections.push(`**${parts.join(" — ")}**`);
}
const awardedEntries = entries
.filter((e) => e.qpEarned > 0)
.toSorted((a, b) => b.qpEarned - a.qpEarned);
if (awardedEntries.length > 0) {
sections.push("\n**Points Awarded**");
for (const e of awardedEntries) {
const managerLabel = e.ownerDiscordUserId
? `<@${e.ownerDiscordUserId}>`
: e.ownerUsername
? escapeMarkdown(e.ownerUsername)
: undefined;
const label = managerLabel
? `${escapeMarkdown(e.participantName)} (${managerLabel})`
: escapeMarkdown(e.participantName);
sections.push(`• **${label}** — +${Math.round(e.qpEarned)} QP`);
}
}
const zeroEntries = entries.filter((e) => e.qpEarned === 0);
if (zeroEntries.length > 0) {
sections.push("\n**No Points**");
for (const e of zeroEntries) {
const managerLabel = e.ownerDiscordUserId
? `<@${e.ownerDiscordUserId}>`
: e.ownerUsername
? escapeMarkdown(e.ownerUsername)
: undefined;
const label = managerLabel
? `${escapeMarkdown(e.participantName)} (${managerLabel})`
: escapeMarkdown(e.participantName);
sections.push(`${label}`);
}
}
const sorted = [...entries].toSorted((a, b) => b.qpTotal - a.qpTotal);
// Compute ranks with tie detection
const ranks: number[] = [];
for (let i = 0; i < sorted.length; i++) {
if (i > 0 && sorted[i].qpTotal === sorted[i - 1].qpTotal) {
ranks.push(ranks[ranks.length - 1]);
} else {
ranks.push(i + 1);
}
}
const isTied = buildTiedRankChecker(ranks);
sections.push("\n**QP Standings**");
for (let i = 0; i < sorted.length; i++) {
const e = sorted[i];
const r = ranks[i];
const rankPrefix = isTied(r) ? `T${r}` : `${r}`;
const ownerLabel = e.ownerDiscordUserId
? `<@${e.ownerDiscordUserId}>`
: e.ownerUsername
? escapeMarkdown(e.ownerUsername)
: undefined;
const managerLabel = ownerLabel ? ` (${ownerLabel})` : "";
sections.push(`${rankPrefix}\\. ${escapeMarkdown(e.participantName)}${managerLabel}${Math.round(e.qpTotal)} QP`);
}
const MAX_DESCRIPTION = 4096;
let description = sections.join("\n");
if (description.length > MAX_DESCRIPTION) {
description = description.slice(0, MAX_DESCRIPTION - 3) + "...";
}
const pingUserIds = new Set<string>();
for (const e of [...awardedEntries, ...zeroEntries]) {
if (e.ownerDiscordUserId) pingUserIds.add(e.ownerDiscordUserId);
}
const pingIds = [...pingUserIds];
const payload: DiscordWebhookPayload = {
embeds: [
{
title: `🏅 Qualifying Points Update — ${seasonName}`,
description,
color: 0x5865f2, // Discord blurple
footer: { text: "brackt.com" },
},
],
};
if (pingIds.length > 0) {
const cappedIds = pingIds.slice(0, 100);
payload.content = cappedIds.map((id) => `<@${id}>`).join(" ");
payload.allowed_mentions = { parse: [], users: cappedIds };
}
await sendDiscordWebhook(webhookUrl, payload);
}
export async function sendPickAnnouncementNotification({
webhookUrl,
draftUrl,

View file

@ -0,0 +1,149 @@
import type { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, inArray } from "drizzle-orm";
import { findDiscordIdsByUserIds } from "~/models/account";
import { getUserDisplayName } from "~/models/user";
import {
sendQualifyingPointsUpdateNotification,
type QPEventEntry,
} from "~/services/discord";
export async function notifyQualifyingPointsUpdate(
sportsSeasonId: string,
scoringEventId: string,
db: ReturnType<typeof database>,
participantIdFilter?: Set<string>
): Promise<void> {
const event = await db.query.scoringEvents.findFirst({
where: eq(schema.scoringEvents.id, scoringEventId),
with: {
sportsSeason: {
with: { sport: true },
},
},
});
if (!event) return;
const eventName = event.name;
const sportName = event.sportsSeason?.sport?.name;
const seasonSports = await db.query.seasonSports.findMany({
where: eq(schema.seasonSports.sportsSeasonId, sportsSeasonId),
});
if (seasonSports.length === 0) return;
// QP earned in this specific event (optionally scoped to participants new/changed in this call)
const eventResultRows = await db.query.eventResults.findMany({
where: eq(schema.eventResults.scoringEventId, scoringEventId),
});
const qpEarnedById = new Map<string, number>(
eventResultRows
.filter((r) => r.qualifyingPointsAwarded !== null)
.filter((r) => !participantIdFilter || participantIdFilter.has(r.seasonParticipantId))
.map((r) => [r.seasonParticipantId, parseFloat(r.qualifyingPointsAwarded as string)])
);
if (qpEarnedById.size === 0) return;
// Current running QP totals for the season
const qpTotals = await db.query.seasonParticipantQualifyingTotals.findMany({
where: eq(schema.seasonParticipantQualifyingTotals.sportsSeasonId, sportsSeasonId),
});
const qpTotalById = new Map<string, number>(
qpTotals.map((t) => [t.participantId, parseFloat(t.totalQualifyingPoints)])
);
const seasonIds = seasonSports.map((s) => s.seasonId);
// Batch-fetch all season + league metadata in one query
const seasons = await db.query.seasons.findMany({
where: inArray(schema.seasons.id, seasonIds),
with: { league: true },
});
const seasonMap = new Map(seasons.map((s) => [s.id, s]));
// Batch-fetch all draft picks for all leagues at once, grouped by season
const allPicks = await db.query.draftPicks.findMany({
where: inArray(schema.draftPicks.seasonId, seasonIds),
with: { team: true },
});
const picksBySeasonId = new Map<string, (typeof allPicks)[number][]>();
for (const pick of allPicks) {
const bucket = picksBySeasonId.get(pick.seasonId);
if (bucket) {
bucket.push(pick);
} else {
picksBySeasonId.set(pick.seasonId, [pick]);
}
}
// Batch-fetch participant display names once (same participants across all leagues)
const allParticipantIds = [...qpEarnedById.keys()];
const participants = await db.query.seasonParticipants.findMany({
where: inArray(schema.seasonParticipants.id, allParticipantIds),
});
const participantNameById = new Map(participants.map((p) => [p.id, p.name]));
// Batch-fetch all team owners and their Discord IDs in two queries (not N per league)
const allOwnerIds = new Set<string>();
for (const pick of allPicks) {
if (pick.team.ownerId) allOwnerIds.add(pick.team.ownerId);
}
const allUsers =
allOwnerIds.size > 0
? await db.query.users.findMany({ where: inArray(schema.users.id, [...allOwnerIds]) })
: [];
const usernameByUserId = new Map(
allUsers
.map((u) => [u.id, getUserDisplayName(u)] as [string, string | null])
.filter((entry): entry is [string, string] => entry[1] !== null)
);
const optedInUserIds = allUsers.filter((u) => u.discordPingEnabled).map((u) => u.id);
const discordIdByUserId = await findDiscordIdsByUserIds(optedInUserIds);
for (const { seasonId } of seasonSports) {
const season = seasonMap.get(seasonId);
const league = season?.league;
const webhookUrl = league?.discordWebhookUrl;
if (!webhookUrl || !season || !league) continue;
const seasonName = `${league.name} ${season.year}`;
const picks = picksBySeasonId.get(seasonId) ?? [];
const teamByParticipantId = new Map<
string,
{ teamName: string; ownerId: string | null }
>();
for (const pick of picks) {
teamByParticipantId.set(pick.participantId, {
teamName: pick.team.name,
ownerId: pick.team.ownerId,
});
}
// Only include participants that appear in both the event and this league's draft
const relevantParticipantIds = [...qpEarnedById.keys()].filter((id) =>
teamByParticipantId.has(id)
);
if (relevantParticipantIds.length === 0) continue;
const entries: QPEventEntry[] = relevantParticipantIds.map((participantId) => {
const ownerId = teamByParticipantId.get(participantId)?.ownerId ?? null;
return {
participantName: participantNameById.get(participantId) ?? participantId,
qpEarned: qpEarnedById.get(participantId) ?? 0,
qpTotal: qpTotalById.get(participantId) ?? 0,
ownerUsername: ownerId ? (usernameByUserId.get(ownerId) ?? undefined) : undefined,
ownerDiscordUserId: ownerId ? discordIdByUserId.get(ownerId) : undefined,
};
});
await sendQualifyingPointsUpdateNotification({
webhookUrl,
seasonName,
sportName,
eventName,
entries,
});
}
}