import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq, inArray, desc, sql, and, isNull } from "drizzle-orm"; import { calculateBracketPoints, calculatePickPoints, type ScoringRules, } from "~/models/scoring-rules"; import { countSharedPlacements, lookupSharedPlacementCount, } from "~/models/participant-result"; import { findParticipantNamesByIds } from "~/models/season-participant"; import { logger } from "~/lib/logger"; /** * Low-level primitive: writes one team_score_events row with an explicit pointsDelta. * participantIds are captured at write time so attribution is accurate regardless * of future match results. * * When matchId is provided (bracket sports), one row is written per match using * a partial unique index on (teamId, seasonId, matchId). When matchId is absent * (non-bracket fallback), one row per (teamId, seasonId, scoringEventId) is used. * * For bracket sports, prefer calling recordMatchScoreEvents instead — it computes * the exact per-season delta from scoring rules rather than requiring the caller * to supply a pre-computed pointsDelta. */ export async function recordTeamScoreEvent( params: { teamId: string; seasonId: string; scoringEventId: string; scoringEventName: string | null; sportName: string | null; participantIds: string[]; pointsDelta: number; occurredAt?: Date; matchId?: string; }, providedDb?: ReturnType ): Promise { const db = providedDb || database(); const values = { teamId: params.teamId, seasonId: params.seasonId, scoringEventId: params.scoringEventId, scoringEventName: params.scoringEventName, sportName: params.sportName, matchId: params.matchId ?? null, participantIds: params.participantIds, pointsDelta: params.pointsDelta.toString(), occurredAt: params.occurredAt ?? new Date(), }; if (params.matchId) { // Per-match path: unique on (teamId, seasonId, matchId) WHERE matchId IS NOT NULL await db .insert(schema.teamScoreEvents) .values(values) .onConflictDoUpdate({ target: [ schema.teamScoreEvents.teamId, schema.teamScoreEvents.seasonId, schema.teamScoreEvents.matchId, ], targetWhere: sql`match_id IS NOT NULL`, set: { participantIds: params.participantIds, pointsDelta: params.pointsDelta.toString(), }, }); } else { // Event-level fallback: unique on (teamId, seasonId, scoringEventId) WHERE matchId IS NULL await db .insert(schema.teamScoreEvents) .values(values) .onConflictDoUpdate({ target: [ schema.teamScoreEvents.teamId, schema.teamScoreEvents.seasonId, schema.teamScoreEvents.scoringEventId, ], targetWhere: sql`match_id IS NULL`, set: { participantIds: params.participantIds, pointsDelta: params.pointsDelta.toString(), // Refreshed too, so a row written with a stale label can be repaired // by a later correct run instead of keeping the wrong name forever. scoringEventName: params.scoringEventName, sportName: params.sportName, }, }); } } /** * Records a score event for every fantasy season that uses the given sports season, * attributing the exact point delta to the specific match winner. * * Called from processMatchResult immediately after upsertParticipantResult sets the * winner's new floor, so the delta is computed from the exact position change rather * than from aggregate before/after standings totals. * * oldFloor is 0 when the participant had no prior result row (first match win). */ export async function recordMatchScoreEvents( params: { participantId: string; sportsSeasonId: string; oldFloor: number; newFloor: number; bracketTemplateId: string | null; matchId: string; eventId: string; eventName: string | null; }, providedDb?: ReturnType ): Promise { const db = providedDb || database(); // Fetch sport name for display (one query, shared across all seasons) const sportsSeason = await db.query.sportsSeasons.findFirst({ where: eq(schema.sportsSeasons.id, params.sportsSeasonId), with: { sport: { columns: { name: true } } }, }); const sportName = sportsSeason?.sport?.name ?? null; // All fantasy seasons that include this sports season const seasonSports = await db.query.seasonSports.findMany({ where: eq(schema.seasonSports.sportsSeasonId, params.sportsSeasonId), columns: { seasonId: true }, }); if (seasonSports.length === 0) return; const seasonIds = seasonSports.map((ss) => ss.seasonId); // Batch fetch: which team in each season drafted this participant? const picks = await db.query.draftPicks.findMany({ where: and( inArray(schema.draftPicks.seasonId, seasonIds), eq(schema.draftPicks.participantId, params.participantId) ), columns: { teamId: true, seasonId: true }, }); if (picks.length === 0) return; const teamBySeasonId = new Map(picks.map((p) => [p.seasonId, p.teamId])); // Batch fetch scoring rules for all seasons in one query const seasonRows = await db.query.seasons.findMany({ where: inArray(schema.seasons.id, seasonIds), columns: { id: true, pointsFor1st: true, pointsFor2nd: true, pointsFor3rd: true, pointsFor4th: true, pointsFor5th: true, pointsFor6th: true, pointsFor7th: true, pointsFor8th: true, }, }); const rulesBySeasonId = new Map( seasonRows.map((s) => [s.id, { pointsFor1st: s.pointsFor1st, pointsFor2nd: s.pointsFor2nd, pointsFor3rd: s.pointsFor3rd, pointsFor4th: s.pointsFor4th, pointsFor5th: s.pointsFor5th, pointsFor6th: s.pointsFor6th, pointsFor7th: s.pointsFor7th, pointsFor8th: s.pointsFor8th, }]) ); for (const seasonId of seasonIds) { const teamId = teamBySeasonId.get(seasonId); if (!teamId) continue; const rules = rulesBySeasonId.get(seasonId); if (!rules) continue; const delta = calculateBracketPoints(params.newFloor, rules, params.bracketTemplateId) - calculateBracketPoints(params.oldFloor, rules, params.bracketTemplateId); if (delta <= 0) continue; try { await recordTeamScoreEvent( { teamId, seasonId, scoringEventId: params.eventId, scoringEventName: params.eventName, sportName, participantIds: [params.participantId], pointsDelta: delta, matchId: params.matchId, }, db ); } catch (err) { logger.error( `[TeamScoreEvents] Failed to record match score event for team ${teamId} match ${params.matchId}:`, err ); } } } /** * Ledger label for a one-shot finalization, derived from the scoring pattern so * every caller agrees. Deriving it here rather than requiring a parameter is * deliberate: recordTeamScoreEvent's upsert can rewrite the label, but a caller * that simply forgets to pass one would otherwise stamp rows with the anchor * event's own name ("The Open") instead of "Final Standings". */ function defaultEventName(scoringPattern: string | null | undefined): string { return scoringPattern === "season_standings" ? "Season Complete" : "Final Standings"; } /** * Records ledger rows for a sports season whose final placements have just been * assigned — qualifying_points (golf, tennis, CS2) and season_standings (F1). * * These patterns award all of their points in one step at finalization, so they * have no per-match deltas and were never written to team_score_events at all: * recordMatchScoreEvents is bracket-only and fires from match processing. The * effect was that golf results silently never appeared in Recent Scores. * * One row per team (matching the event-level unique index), carrying that team's * summed award and every participant that contributed. Points come from * calculatePickPoints, so a tied golfer contributes the same split award the * standings show. * * Every row is anchored to a real scoring event, because the event-level unique * index is (teamId, seasonId, scoringEventId) and Postgres treats NULLs as * distinct — a null anchor would defeat the upsert entirely. If no anchor can be * found the ledger write is skipped rather than risking duplicates; standings are * unaffected either way. * * Re-running is safe even if the anchor moves (a later event completes, or the * backfill runs against changed data). Stale rows for this sports season are * cleared before writing, so the upsert alone is not load-bearing. */ export async function recordFinalPlacementScoreEvents( params: { sportsSeasonId: string; eventId?: string | null; eventName?: string | null; }, providedDb?: ReturnType ): Promise { const db = providedDb || database(); const sportsSeason = await db.query.sportsSeasons.findFirst({ where: eq(schema.sportsSeasons.id, params.sportsSeasonId), columns: { id: true, scoringPattern: true }, with: { sport: { columns: { name: true } } }, }); if (!sportsSeason) return; // Resolve the anchor event: the caller's, else the most recently completed // event for this sports season. // // The completed filter is not cosmetic. drizzle's desc() emits a bare `desc`, // and Postgres orders DESC as NULLS FIRST, so ordering on completedAt alone // would rank a never-completed event (common for sibling major windows) above // every finished one. Restrict to completed events and order explicitly. let eventId = params.eventId ?? null; const eventName = params.eventName ?? defaultEventName(sportsSeason.scoringPattern); if (!eventId) { const anchor = await db.query.scoringEvents.findFirst({ where: and( eq(schema.scoringEvents.sportsSeasonId, params.sportsSeasonId), eq(schema.scoringEvents.isComplete, true) ), columns: { id: true }, orderBy: [ sql`${schema.scoringEvents.completedAt} DESC NULLS LAST`, sql`${schema.scoringEvents.eventDate} DESC NULLS LAST`, desc(schema.scoringEvents.id), ], }); if (!anchor) { logger.warn( `[TeamScoreEvents] No completed scoring event to anchor final placements for sports season ${params.sportsSeasonId}; skipping ledger write` ); return; } eventId = anchor.id; } // Scoring placements for this sports season, plus the tie spans they imply. // Counted from these same rows rather than re-querying them. const results = await db.query.seasonParticipantResults.findMany({ where: eq(schema.seasonParticipantResults.sportsSeasonId, params.sportsSeasonId), columns: { sportsSeasonId: true, participantId: true, finalPosition: true }, }); const positionByParticipantId = new Map(); for (const row of results) { if (row.finalPosition !== null && row.finalPosition > 0) { positionByParticipantId.set(row.participantId, row.finalPosition); } } if (positionByParticipantId.size === 0) return; const sharedPlacementCounts = countSharedPlacements(results); const seasonSports = await db.query.seasonSports.findMany({ where: eq(schema.seasonSports.sportsSeasonId, params.sportsSeasonId), columns: { seasonId: true }, }); if (seasonSports.length === 0) return; const seasonIds = seasonSports.map((ss) => ss.seasonId); const picks = await db.query.draftPicks.findMany({ where: and( inArray(schema.draftPicks.seasonId, seasonIds), inArray(schema.draftPicks.participantId, [...positionByParticipantId.keys()]) ), columns: { teamId: true, seasonId: true, participantId: true }, }); if (picks.length === 0) return; const seasonRows = await db.query.seasons.findMany({ where: inArray(schema.seasons.id, seasonIds), columns: { id: true, pointsFor1st: true, pointsFor2nd: true, pointsFor3rd: true, pointsFor4th: true, pointsFor5th: true, pointsFor6th: true, pointsFor7th: true, pointsFor8th: true, }, }); const rulesBySeasonId = new Map( seasonRows.map((s) => [s.id, { pointsFor1st: s.pointsFor1st, pointsFor2nd: s.pointsFor2nd, pointsFor3rd: s.pointsFor3rd, pointsFor4th: s.pointsFor4th, pointsFor5th: s.pointsFor5th, pointsFor6th: s.pointsFor6th, pointsFor7th: s.pointsFor7th, pointsFor8th: s.pointsFor8th, }]) ); // Accumulate per (season, team) — the ledger's event-level grain. const byTeam = new Map< string, { teamId: string; seasonId: string; points: number; participantIds: string[] } >(); for (const pick of picks) { const rules = rulesBySeasonId.get(pick.seasonId); const finalPosition = positionByParticipantId.get(pick.participantId); if (!rules || finalPosition === undefined) continue; const points = calculatePickPoints( finalPosition, sportsSeason.scoringPattern, rules, { tiedParticipants: lookupSharedPlacementCount( sharedPlacementCounts, params.sportsSeasonId, finalPosition ), } ); if (points <= 0) continue; const key = `${pick.seasonId}:${pick.teamId}`; const entry = byTeam.get(key) ?? { teamId: pick.teamId, seasonId: pick.seasonId, points: 0, participantIds: [], }; entry.points += points; entry.participantIds.push(pick.participantId); byTeam.set(key, entry); } // Clear this sports season's existing event-level rows before writing, so a // re-run whose anchor landed on a different event replaces rather than adds. // Scoped to match_id IS NULL, which is what makes this safe: one-shot patterns // write no other event-level rows (QP majors award qualifying points, not // fantasy points), and qualifying-bracket matches carry a non-null matchId. const eventIds = await db.query.scoringEvents.findMany({ where: eq(schema.scoringEvents.sportsSeasonId, params.sportsSeasonId), columns: { id: true }, }); if (eventIds.length > 0) { await db .delete(schema.teamScoreEvents) .where( and( isNull(schema.teamScoreEvents.matchId), inArray( schema.teamScoreEvents.scoringEventId, eventIds.map((e) => e.id) ), inArray(schema.teamScoreEvents.seasonId, seasonIds) ) ); } for (const entry of byTeam.values()) { try { await recordTeamScoreEvent( { teamId: entry.teamId, seasonId: entry.seasonId, scoringEventId: eventId, scoringEventName: eventName, sportName: sportsSeason.sport?.name ?? null, participantIds: entry.participantIds, pointsDelta: entry.points, }, db ); } catch (err) { logger.error( `[TeamScoreEvents] Failed to record final placement score event for team ${entry.teamId} sports season ${params.sportsSeasonId}:`, err ); } } } export interface TeamScoreEventEntry { id: string; teamId: string; teamName: string; scoringEventId: string | null; scoringEventName: string | null; sportName: string | null; pointsDelta: string; occurredAt: Date; participants: Array<{ id: string; name: string }>; } /** * Returns the most recent scoring events for a league season, ordered by * occurredAt DESC. Participant names are fetched from the stored participantIds. */ export async function getRecentTeamScoreEvents( seasonId: string, limit = 10, providedDb?: ReturnType ): Promise { const db = providedDb || database(); const rows = await db.query.teamScoreEvents.findMany({ where: eq(schema.teamScoreEvents.seasonId, seasonId), with: { team: { columns: { id: true, name: true } }, }, orderBy: [desc(schema.teamScoreEvents.occurredAt)], limit, }); if (rows.length === 0) return []; // Batch-fetch participant names for all stored participant IDs const allParticipantIds = [ ...new Set(rows.flatMap((r) => r.participantIds ?? [])), ]; const participantNameById = await findParticipantNamesByIds(db, allParticipantIds); return rows.map((row) => ({ id: row.id, teamId: row.teamId, teamName: row.team.name, scoringEventId: row.scoringEventId, scoringEventName: row.scoringEventName, sportName: row.sportName, pointsDelta: row.pointsDelta, occurredAt: row.occurredAt, participants: (row.participantIds ?? []) .map((id) => { const name = participantNameById.get(id); return name ? { id, name } : null; }) .filter((p): p is { id: string; name: string } => p !== null), })); }