brackt/app/services/sync-tournament-results.ts

138 lines
4.7 KiB
TypeScript

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<SyncReport> {
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;
}