diff --git a/app/models/cs2-major-stage.ts b/app/models/cs2-major-stage.ts index df7258c..3049240 100644 --- a/app/models/cs2-major-stage.ts +++ b/app/models/cs2-major-stage.ts @@ -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(() => {}); } diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index 6e56f20..ef12e60 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -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); + } } /** diff --git a/app/services/__tests__/discord.test.ts b/app/services/__tests__/discord.test.ts index f88a9d9..259c771 100644 --- a/app/services/__tests__/discord.test.ts +++ b/app/services/__tests__/discord.test.ts @@ -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, diff --git a/app/services/discord.ts b/app/services/discord.ts index 7e49c88..84efeff 100644 --- a/app/services/discord.ts +++ b/app/services/discord.ts @@ -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 { + 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(); + 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, diff --git a/app/services/qualifying-points-discord.server.ts b/app/services/qualifying-points-discord.server.ts new file mode 100644 index 0000000..2b31b05 --- /dev/null +++ b/app/services/qualifying-points-discord.server.ts @@ -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 +): Promise { + 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( + 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( + 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, + }); + } +}