Four related bugs in the Qualifying Points subsystem surfaced on Wimbledon: 1. Some leagues stored 2 QP instead of 1.5 for Round-of-16 losers. Sibling/ mirror windows scored via the placement-group path in processQualifyingEvent, which took the tie span from the players present on that window's roster (a draftable subset) instead of the full field. A window holding fewer than the 8 tied R16 losers split the 9-16 points too few ways and over-awarded. The tie span is now derived from the canonical tournament_results (the whole field) for tournament-linked events, falling back to the live count only for standalone events. Every league now scores identically. 2. Discord notifications rounded QP with Math.round, turning 1.5 into "2". Both the "Points Awarded" and "QP Standings" values now use a 2-decimal formatter mirroring the web UI's formatQP, so Discord and the site agree. 3. The Discord "QP Standings" block ranked players only among that event's scorers, showing two R16 losers as T1 instead of T9. Rank is now computed over the full season field and passed through to the notifier. 4. "N of M majors completed" reached "11 of 4": majorsCompleted was a stored counter incremented on every fan-out sync with a guard that misfired. It is now derived on read (getMajorsCompleted = count of completed qualifying events), which is self-correcting; the increment/decrement writes are removed along with the now-unused hasProcessedQualifyingPlacement helper. Adds scripts/backfill-qp-resplit.ts to silently re-score existing majors so already-corrupted seasons are corrected (2 -> 1.5), and threads a skipNotifications option through processQualifyingEvent / syncTournamentResults so the backfill does not re-ping every league. Co-Authored-By: Claude Opus 4.8 <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 }))
|
|
.sort((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,
|
|
});
|
|
}
|
|
}
|