Add Discord notifications for qualifying points updates

Fire a gold-colored Discord embed whenever QP is awarded during a season
(tennis/golf majors via processQualifyingEvent, CS2 Swiss stages via
assignCs2EliminationQP) so league members see progress throughout the
season rather than only when QP finalize into fantasy placements.

- discord.ts: QPEventEntry interface + sendQualifyingPointsUpdateNotification
  (gold embed with Points Awarded, No Points, and QP Standings sections)
- qualifying-points-discord.server.ts: notifyQualifyingPointsUpdate — looks
  up affected leagues, maps drafted participants to teams/owners, assembles
  entries, and fires the notification per league webhook
- scoring-calculator.ts: call notifyQualifyingPointsUpdate at end of
  processQualifyingEvent (covers tennis, golf, CS2 Champions bracket)
- cs2-major-stage.ts: call notifyQualifyingPointsUpdate at end of
  assignCs2EliminationQP (covers all CS2 Swiss stages incl. 0-QP exits)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnJP1NTYXHxL2kbE4fZaD6
This commit is contained in:
Claude 2026-07-01 16:05:44 +00:00
parent 64e5cb23ef
commit df15386d2d
No known key found for this signature in database
5 changed files with 450 additions and 1 deletions

View file

@ -17,6 +17,7 @@ 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";
export interface Cs2StageResult {
id: string;
@ -439,4 +440,6 @@ export async function assignCs2EliminationQP(
}
await writeEventResultsQP(scoringEventId, sportsSeasonId, resultsByParticipant, db);
await notifyQualifyingPointsUpdate(sportsSeasonId, scoringEventId, db).catch(() => {});
}

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(0xffd700);
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

@ -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}** — +${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}${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: 0xffd700, // Gold — distinguishable from standings 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,138 @@
import { 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>
): 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
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)
.map((r) => [r.seasonParticipantId, parseFloat(r.qualifyingPointsAwarded!)])
);
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)])
);
for (const { seasonId } of seasonSports) {
const season = await db.query.seasons.findFirst({
where: eq(schema.seasons.id, seasonId),
with: { league: true },
});
const webhookUrl = season?.league?.discordWebhookUrl;
if (!webhookUrl) continue;
const seasonName = `${season!.league!.name} ${season!.year}`;
// Map drafted participants → teams in this season
const picks = await db.query.draftPicks.findMany({
where: eq(schema.draftPicks.seasonId, seasonId),
with: { team: true },
});
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;
// Participant display names
const participants = await db.query.seasonParticipants.findMany({
where: inArray(schema.seasonParticipants.id, relevantParticipantIds),
});
const participantNameById = new Map(participants.map((p) => [p.id, p.name]));
// Owner usernames + Discord IDs
const ownerIds = [
...new Set(
relevantParticipantIds
.map((id) => teamByParticipantId.get(id)?.ownerId)
.filter((id): id is string => id !== null && id !== undefined)
),
];
const users = ownerIds.length
? await db.query.users.findMany({
where: inArray(schema.users.id, ownerIds),
})
: [];
const usernameByUserId = new Map(
users
.map((u) => [u.id, getUserDisplayName(u)] as [string, string | null])
.filter((entry): entry is [string, string] => entry[1] !== null)
);
const optedInUserIds = users.filter((u) => u.discordPingEnabled).map((u) => u.id);
const discordIdByUserId = await findDiscordIdsByUserIds(optedInUserIds);
const entries: QPEventEntry[] = relevantParticipantIds.map((participantId) => {
const teamInfo = teamByParticipantId.get(participantId)!;
const ownerId = teamInfo.ownerId;
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,
});
}
}