- Use Array#toSorted() instead of Array#sort() in the QP global-rank builder (qualifying-points-discord.server.ts) and the test's withRanks helper. - Move the discord.test.ts withRanks helper to module scope — it captured nothing from the enclosing describe block, which oxlint's consistent-function-scoping rule flags as an error. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017AUcHy9m7aF6axsY6Grpe4
214 lines
8.6 KiB
TypeScript
214 lines
8.6 KiB
TypeScript
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<typeof database>,
|
|
participantIdFilter?: Set<string>,
|
|
/**
|
|
* 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<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)])
|
|
);
|
|
|
|
// 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<string, (typeof allPicks)[number][]>();
|
|
const draftedParticipantIds = new Set<string>();
|
|
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<string, number>(
|
|
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<string, number>();
|
|
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<number, number>();
|
|
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<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)
|
|
);
|
|
// 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,
|
|
});
|
|
}
|
|
}
|