brackt/app/services/qualifying-points-discord.server.ts

178 lines
6.7 KiB
TypeScript
Raw Normal View History

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;
// 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)])
);
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<string, (typeof allPicks)[number][]>();
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 = [...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;
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,
};
});
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,
});
}
}