139 lines
4.7 KiB
TypeScript
139 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;
|
||
|
|
}
|