diff --git a/app/models/cs2-major-stage.ts b/app/models/cs2-major-stage.ts index df7258c..9de7bb0 100644 --- a/app/models/cs2-major-stage.ts +++ b/app/models/cs2-major-stage.ts @@ -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( + 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( + [...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); + } + } } 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..d4be147 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(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, diff --git a/app/services/__tests__/qualifying-points-discord.server.test.ts b/app/services/__tests__/qualifying-points-discord.server.test.ts new file mode 100644 index 0000000..ce7f197 --- /dev/null +++ b/app/services/__tests__/qualifying-points-discord.server.test.ts @@ -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 = {}) { + 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(); + }); +}); diff --git a/app/services/discord.ts b/app/services/discord.ts index 7e49c88..50bbbd2 100644 --- a/app/services/discord.ts +++ b/app/services/discord.ts @@ -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 { + 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(); + 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, diff --git a/app/services/qualifying-points-discord.server.ts b/app/services/qualifying-points-discord.server.ts new file mode 100644 index 0000000..b1d8144 --- /dev/null +++ b/app/services/qualifying-points-discord.server.ts @@ -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, + participantIdFilter?: Set +): 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 (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( + 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( + 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(); + 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(); + 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, + }); + } +}