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, type QPEliminatedEntry, } from "~/services/discord"; export async function notifyQualifyingPointsUpdate( sportsSeasonId: string, scoringEventId: string, db: ReturnType, participantIdFilter?: Set, /** * Participants knocked out this sync in a non-scoring round: they earn no QP, * so they never appear via qualifyingPointsAwarded, but a manager who drafted * them should still be told their player is out. Surfaced in a "Knocked Out" * section, deduped against QP earners. */ eliminatedParticipantIds?: 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)]) ); // Knocked-out players with no QP change. Exclude anyone who also earned QP this // sync (e.g. a Round-of-16 loser) so they aren't listed twice. const eliminatedIds = new Set( [...(eliminatedParticipantIds ?? [])].filter((id) => !qpEarnedById.has(id)) ); if (qpEarnedById.size === 0 && eliminatedIds.size === 0) return; const seasonIds = seasonSports.map((s) => s.seasonId); // Batch-fetch all draft picks for all leagues at once, grouped by season. // Fetched before the remaining lookups so we can short-circuit when nothing // that happened this sync was actually drafted: during a Grand Slam's early // rounds most eliminated players are undrafted, and the notifier now fires on // every sync that has any elimination — no point running the rest of the // queries just to send nothing. const allPicks = await db.query.draftPicks.findMany({ where: inArray(schema.draftPicks.seasonId, seasonIds), with: { team: true }, }); const picksBySeasonId = new Map(); const draftedParticipantIds = new Set(); for (const pick of allPicks) { draftedParticipantIds.add(pick.participantId); const bucket = picksBySeasonId.get(pick.seasonId); if (bucket) { bucket.push(pick); } else { picksBySeasonId.set(pick.seasonId, [pick]); } } const hasDraftedQP = [...qpEarnedById.keys()].some((id) => draftedParticipantIds.has(id)); const hasDraftedEliminated = [...eliminatedIds].some((id) => draftedParticipantIds.has(id)); if (!hasDraftedQP && !hasDraftedEliminated) 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)]) ); // Rank every participant across the FULL season field (not just this event's // scorers) so the notification's "QP Standings" block reads as a season-leaderboard // slice and matches the website's globalRank. Standard competition ranking: ties // share the lower rank (…, 9, 9, 11, …). Two R16 losers on 1.5 QP with 8 players // ahead therefore render as T9, not T1 among just the two of them. const rankedField = [...qpTotalById.entries()] .map(([id, total]) => ({ id, total })) .toSorted((a, b) => b.total - a.total); const globalRankById = new Map(); let prevTotal = Number.NaN; let prevRank = 0; rankedField.forEach((row, index) => { const rank = index > 0 && Math.abs(row.total - prevTotal) < 0.001 ? prevRank : index + 1; globalRankById.set(row.id, rank); prevTotal = row.total; prevRank = rank; }); const countByRank = new Map(); for (const rank of globalRankById.values()) { countByRank.set(rank, (countByRank.get(rank) ?? 0) + 1); } // 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 participant display names once (same participants across all leagues) const allParticipantIds = [...new Set([...qpEarnedById.keys(), ...eliminatedIds])]; 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}`; // Link the embed title to this league's sport-season standings page. The // sportsSeasonId is exactly what the /leagues/:leagueId/sports-seasons/:sportsSeasonId // route expects (the page filters seasonSports by leagueId). const appUrl = process.env.APP_URL ?? "https://brackt.com"; const standingsUrl = `${appUrl}/leagues/${league.id}/sports-seasons/${sportsSeasonId}`; 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) ); // Knocked-out players drafted in this league (0 QP, non-scoring-round exits) const eliminatedForLeague = [...eliminatedIds].filter((id) => teamByParticipantId.has(id) ); if (relevantParticipantIds.length === 0 && eliminatedForLeague.length === 0) continue; const entries: QPEventEntry[] = relevantParticipantIds.map((participantId) => { const ownerId = teamByParticipantId.get(participantId)?.ownerId ?? null; const globalRank = globalRankById.get(participantId) ?? 0; return { participantName: participantNameById.get(participantId) ?? participantId, qpEarned: qpEarnedById.get(participantId) ?? 0, qpTotal: qpTotalById.get(participantId) ?? 0, globalRank, globalRankTied: (countByRank.get(globalRank) ?? 0) > 1, ownerUsername: ownerId ? (usernameByUserId.get(ownerId) ?? undefined) : undefined, ownerDiscordUserId: ownerId ? discordIdByUserId.get(ownerId) : undefined, }; }); const eliminated: QPEliminatedEntry[] = eliminatedForLeague.map((participantId) => { const ownerId = teamByParticipantId.get(participantId)?.ownerId ?? null; return { participantName: participantNameById.get(participantId) ?? participantId, ownerUsername: ownerId ? (usernameByUserId.get(ownerId) ?? undefined) : undefined, ownerDiscordUserId: ownerId ? discordIdByUserId.get(ownerId) : undefined, }; }); await sendQualifyingPointsUpdateNotification({ webhookUrl, seasonName, sportName, eventName, entries, eliminated, standingsUrl, }); } }