import { eq, and } from "drizzle-orm"; import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { processQualifyingEvent } from "~/models/scoring-calculator"; export interface SyncReport { tournamentId: string; windowsSynced: number; windowsFailed: number; failures: Array<{ scoringEventId: string; sportsSeasonId: string; error: string; }>; } /** * Fan out canonical tournament_results to every scoring_event window that * points at this tournament. For each linked window: * 1. Upsert event_results rows mirroring canonical placement / rawScore for * every roster participant that appears in the canonical results. * 2. Write 0-QP filler rows for roster participants who have no result. * 3. Delegate to processQualifyingEvent to compute QP. * * Each window runs in its own transaction so partial failures isolate. * Errors in one window never interrupt sync of the remaining windows. * * This service NEVER writes qualifying_points_awarded directly (except the * "0" filler for participants with no placement) — QP calculation for real * results is owned by processQualifyingEvent. */ export async function syncTournamentResults( tournamentId: string ): Promise { const db = database(); const report: SyncReport = { tournamentId, windowsSynced: 0, windowsFailed: 0, failures: [], }; // 1. Load canonical results for this tournament. const canonicalResults = await db .select() .from(schema.tournamentResults) .where(eq(schema.tournamentResults.tournamentId, tournamentId)); // 2. Load every scoring_event that points at this tournament. const linkedEvents = await db .select() .from(schema.scoringEvents) .where(eq(schema.scoringEvents.tournamentId, tournamentId)); // 3. Fan out — each event gets its own transaction. for (const ev of linkedEvents) { try { await db.transaction(async (tx: typeof db) => { // 3a. Window roster = season_participants for this event's sports_season. const rosters = await tx .select() .from(schema.seasonParticipants) .where(eq(schema.seasonParticipants.sportsSeasonId, ev.sportsSeasonId)); // 3b. For each canonical result whose participant is on the roster, // upsert the matching event_results row. Only placement / rawScore // are copied; qualifying_points_awarded is left for // processQualifyingEvent to recompute. for (const cr of canonicalResults) { const sp = rosters.find((r) => r.participantId === cr.participantId); if (!sp) continue; const [existing] = await tx .select() .from(schema.eventResults) .where( and( eq(schema.eventResults.scoringEventId, ev.id), eq(schema.eventResults.seasonParticipantId, sp.id) ) ); if (existing) { await tx .update(schema.eventResults) .set({ placement: cr.placement, rawScore: cr.rawScore, updatedAt: new Date(), }) .where(eq(schema.eventResults.id, existing.id)); } else { await tx.insert(schema.eventResults).values({ scoringEventId: ev.id, seasonParticipantId: sp.id, placement: cr.placement, rawScore: cr.rawScore, }); } } // 3c. 0-QP filler pass: every roster member without an event_results // row for this event gets one with placement=null, QP=0. This matches // the behavior from the per-window batch-qualifying-results design. const afterRows = await tx .select() .from(schema.eventResults) .where(eq(schema.eventResults.scoringEventId, ev.id)); const covered = new Set(afterRows.map((r) => r.seasonParticipantId)); for (const sp of rosters) { if (covered.has(sp.id)) continue; await tx.insert(schema.eventResults).values({ scoringEventId: ev.id, seasonParticipantId: sp.id, placement: null, qualifyingPointsAwarded: "0", }); } // 3d. Delegate to scoring engine inside the same transaction. await processQualifyingEvent(ev.id, tx); }); report.windowsSynced += 1; } catch (e: unknown) { report.windowsFailed += 1; const msg = e instanceof Error ? e.message : typeof e === "string" ? e : String(e); report.failures.push({ scoringEventId: ev.id, sportsSeasonId: ev.sportsSeasonId, error: msg, }); } } return report; }