Merge branch 'phase-3-cutover' into canonical-layer-pr2
This commit is contained in:
commit
93237ede0a
20 changed files with 7505 additions and 77 deletions
|
|
@ -46,12 +46,18 @@ interface BatchResultEntryProps {
|
|||
participants: Participant[];
|
||||
sportsSeasonId: string;
|
||||
existingResultParticipantIds: Set<string>;
|
||||
/**
|
||||
* Form intent name posted on "Save All". Defaults to "batch-add-results" for
|
||||
* the existing per-window flow. Canonical tournament pages override this.
|
||||
*/
|
||||
intent?: string;
|
||||
}
|
||||
|
||||
export function BatchResultEntry({
|
||||
participants,
|
||||
sportsSeasonId,
|
||||
existingResultParticipantIds,
|
||||
intent = "batch-add-results",
|
||||
}: BatchResultEntryProps) {
|
||||
const [stage, setStage] = useState<Stage>("idle");
|
||||
const [pasteText, setPasteText] = useState("");
|
||||
|
|
@ -172,7 +178,7 @@ export function BatchResultEntry({
|
|||
placement: r.placement,
|
||||
}));
|
||||
const formData = new FormData();
|
||||
formData.set("intent", "batch-add-results");
|
||||
formData.set("intent", intent);
|
||||
formData.set("results", JSON.stringify(results));
|
||||
saveFetcher.submit(formData, { method: "post" });
|
||||
}
|
||||
|
|
|
|||
50
app/lib/tournament-identity.ts
Normal file
50
app/lib/tournament-identity.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* Extracts the canonical tournament identity — `(name, year)` — from a scoring
|
||||
* event. Used both by the Phase 2 backfill and by the admin create-event flow
|
||||
* that auto-provisions a canonical tournament for new qualifying events.
|
||||
*
|
||||
* Rules:
|
||||
* - If the event name ends with a 4-digit year, strip it; that year wins.
|
||||
* - Otherwise, fall back to the year portion of eventDate (YYYY-MM-DD).
|
||||
* - If neither produces a year, throw.
|
||||
*/
|
||||
|
||||
export interface ScoringEventIdentityInput {
|
||||
name: string;
|
||||
eventDate: string | Date | null | undefined;
|
||||
eventType: string;
|
||||
}
|
||||
|
||||
export interface TournamentIdentity {
|
||||
name: string;
|
||||
year: number;
|
||||
}
|
||||
|
||||
const YEAR_SUFFIX = /\s+(\d{4})\s*$/;
|
||||
|
||||
export function extractTournamentIdentity(ev: ScoringEventIdentityInput): TournamentIdentity {
|
||||
const trimmed = ev.name.trim();
|
||||
const yearMatch = trimmed.match(YEAR_SUFFIX);
|
||||
let cleanName = trimmed;
|
||||
let year: number | null = null;
|
||||
|
||||
if (yearMatch) {
|
||||
year = parseInt(yearMatch[1], 10);
|
||||
cleanName = trimmed.replace(YEAR_SUFFIX, "").trim();
|
||||
}
|
||||
|
||||
if (year === null && ev.eventDate != null) {
|
||||
const iso = ev.eventDate instanceof Date
|
||||
? ev.eventDate.toISOString()
|
||||
: String(ev.eventDate);
|
||||
year = parseInt(iso.slice(0, 4), 10);
|
||||
}
|
||||
|
||||
if (year === null || Number.isNaN(year)) {
|
||||
throw new Error(
|
||||
`cannot determine year for event "${ev.name}" — no year in name and eventDate is null`,
|
||||
);
|
||||
}
|
||||
|
||||
return { name: cleanName, year };
|
||||
}
|
||||
|
|
@ -42,10 +42,10 @@ describe("getSurfaceEloMap", () => {
|
|||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("returns a Map keyed by participantId", async () => {
|
||||
it("returns a Map keyed by seasonParticipantId", async () => {
|
||||
mockDb.where.mockResolvedValue([
|
||||
{ participantId: "p1", worldRanking: 1, eloHard: 1800, eloClay: 1700, eloGrass: 1600 },
|
||||
{ participantId: "p2", worldRanking: null, eloHard: null, eloClay: 1750, eloGrass: null },
|
||||
{ seasonParticipantId: "p1", worldRanking: 1, eloHard: 1800, eloClay: 1700, eloGrass: 1600 },
|
||||
{ seasonParticipantId: "p2", worldRanking: null, eloHard: null, eloClay: 1750, eloGrass: null },
|
||||
]);
|
||||
const result = await getSurfaceEloMap("season-1");
|
||||
expect(result.size).toBe(2);
|
||||
|
|
@ -55,7 +55,7 @@ describe("getSurfaceEloMap", () => {
|
|||
|
||||
it("preserves null surface Elos (does not coerce to 0)", async () => {
|
||||
mockDb.where.mockResolvedValue([
|
||||
{ participantId: "p1", worldRanking: null, eloHard: null, eloClay: null, eloGrass: null },
|
||||
{ seasonParticipantId: "p1", worldRanking: null, eloHard: null, eloClay: null, eloGrass: null },
|
||||
]);
|
||||
const result = await getSurfaceEloMap("season-1");
|
||||
const entry = result.get("p1");
|
||||
|
|
@ -72,7 +72,13 @@ describe("batchUpsertSurfaceElos", () => {
|
|||
expect(mockDb.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls insert with the correct values", async () => {
|
||||
it("calls insert with the correct values on the per-window table", async () => {
|
||||
// The function first upserts to the per-window table, then reads
|
||||
// season_participants for canonical-id lookup. Make the follow-up select
|
||||
// return empty so the canonical write path short-circuits and we only
|
||||
// observe the per-window insert in this assertion.
|
||||
mockDb.where.mockResolvedValue([]);
|
||||
|
||||
const inputs = [
|
||||
{ participantId: "p1", sportsSeasonId: "s1", worldRanking: 1, eloHard: 1800, eloClay: 1700, eloGrass: 1600 },
|
||||
{ participantId: "p2", sportsSeasonId: "s1", worldRanking: null, eloHard: null, eloClay: 1750, eloGrass: undefined },
|
||||
|
|
@ -95,11 +101,36 @@ describe("batchUpsertSurfaceElos", () => {
|
|||
});
|
||||
|
||||
it("uses onConflictDoUpdate for upsert behaviour", async () => {
|
||||
mockDb.where.mockResolvedValue([]); // no canonical links; only per-window write
|
||||
|
||||
await batchUpsertSurfaceElos([
|
||||
{ participantId: "p1", sportsSeasonId: "s1", worldRanking: 5, eloHard: 2000 },
|
||||
]);
|
||||
expect(mockDb.onConflictDoUpdate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("mirrors writes to canonical participant_surface_elos when linked", async () => {
|
||||
// season_participants lookup returns canonical links for p1 only.
|
||||
mockDb.where.mockResolvedValue([
|
||||
{ id: "p1", canonicalId: "canon-1" },
|
||||
]);
|
||||
|
||||
await batchUpsertSurfaceElos([
|
||||
{ participantId: "p1", sportsSeasonId: "s1", worldRanking: 5, eloHard: 2000 },
|
||||
]);
|
||||
|
||||
// One per-window insert, one canonical insert → two inserts total.
|
||||
expect(mockDb.insert).toHaveBeenCalledTimes(2);
|
||||
expect(mockDb.onConflictDoUpdate).toHaveBeenCalledTimes(2);
|
||||
|
||||
const canonicalValues = mockDb.values.mock.calls[1][0] as Array<{
|
||||
participantId: string;
|
||||
eloHard: number | null;
|
||||
}>;
|
||||
expect(canonicalValues).toHaveLength(1);
|
||||
expect(canonicalValues[0].participantId).toBe("canon-1");
|
||||
expect(canonicalValues[0].eloHard).toBe(2000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSurfaceElosForSeason", () => {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ export interface CreateScoringEventData {
|
|||
eventStartsAt?: Date;
|
||||
eventType: EventType;
|
||||
isQualifyingEvent?: boolean;
|
||||
/** Required when isQualifyingEvent=true (DB check constraint). */
|
||||
tournamentId?: string;
|
||||
// Template system fields (Phase 2.6)
|
||||
bracketTemplateId?: string;
|
||||
scoringStartsAtRound?: string;
|
||||
|
|
@ -59,6 +61,7 @@ export async function createScoringEvent(
|
|||
eventStartsAt: data.eventStartsAt,
|
||||
eventType: data.eventType,
|
||||
isQualifyingEvent: data.isQualifyingEvent || false,
|
||||
tournamentId: data.tournamentId,
|
||||
bracketTemplateId: data.bracketTemplateId,
|
||||
scoringStartsAtRound: data.scoringStartsAtRound,
|
||||
isComplete: false,
|
||||
|
|
@ -743,7 +746,7 @@ export async function getEventsForDates(
|
|||
*/
|
||||
export async function bulkCreateScoringEvents(
|
||||
sportsSeasonId: string,
|
||||
events: Array<{ name: string; eventDate?: Date; eventStartsAt?: Date; eventType: EventType; isQualifyingEvent?: boolean }>,
|
||||
events: Array<{ name: string; eventDate?: Date; eventStartsAt?: Date; eventType: EventType; isQualifyingEvent?: boolean; tournamentId?: string }>,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
) {
|
||||
const db = providedDb || database();
|
||||
|
|
@ -757,6 +760,7 @@ export async function bulkCreateScoringEvents(
|
|||
eventStartsAt: e.eventStartsAt,
|
||||
eventType: e.eventType,
|
||||
isQualifyingEvent: e.isQualifyingEvent ?? false,
|
||||
tournamentId: e.tournamentId,
|
||||
isComplete: false,
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -7,18 +7,86 @@ export type NewParticipant = typeof schema.seasonParticipants.$inferInsert;
|
|||
|
||||
export async function createParticipant(data: NewParticipant): Promise<Participant> {
|
||||
const db = database();
|
||||
|
||||
// Auto-link to canonical participant (by sportId + name) if caller didn't
|
||||
// supply one. Needed so syncTournamentResults can match this roster entry
|
||||
// to canonical tournament_results; without the link the participant is
|
||||
// invisible to the canonical layer.
|
||||
let canonicalId = data.participantId;
|
||||
if (!canonicalId) {
|
||||
const season = await db.query.sportsSeasons.findFirst({
|
||||
where: eq(schema.sportsSeasons.id, data.sportsSeasonId),
|
||||
columns: { sportId: true },
|
||||
});
|
||||
if (season) {
|
||||
const existing = await db.query.participants.findFirst({
|
||||
where: and(
|
||||
eq(schema.participants.sportId, season.sportId),
|
||||
eq(schema.participants.name, data.name),
|
||||
),
|
||||
});
|
||||
if (existing) {
|
||||
canonicalId = existing.id;
|
||||
} else {
|
||||
const [created] = await db
|
||||
.insert(schema.participants)
|
||||
.values({ sportId: season.sportId, name: data.name })
|
||||
.returning();
|
||||
canonicalId = created.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [participant] = await db
|
||||
.insert(schema.seasonParticipants)
|
||||
.values(data)
|
||||
.values({ ...data, participantId: canonicalId })
|
||||
.returning();
|
||||
return participant;
|
||||
}
|
||||
|
||||
export async function createManyParticipants(data: NewParticipant[]): Promise<Participant[]> {
|
||||
const db = database();
|
||||
if (data.length === 0) return [];
|
||||
|
||||
// Build canonical links per row, just like createParticipant does.
|
||||
const seasonIds = Array.from(new Set(data.map((d) => d.sportsSeasonId)));
|
||||
const seasons = await db.query.sportsSeasons.findMany({
|
||||
where: inArray(schema.sportsSeasons.id, seasonIds),
|
||||
columns: { id: true, sportId: true },
|
||||
});
|
||||
const sportBySeason = new Map(seasons.map((s) => [s.id, s.sportId]));
|
||||
|
||||
const enriched: NewParticipant[] = [];
|
||||
for (const row of data) {
|
||||
if (row.participantId) {
|
||||
enriched.push(row);
|
||||
continue;
|
||||
}
|
||||
const sportId = sportBySeason.get(row.sportsSeasonId);
|
||||
if (!sportId) {
|
||||
enriched.push(row);
|
||||
continue;
|
||||
}
|
||||
const existing = await db.query.participants.findFirst({
|
||||
where: and(
|
||||
eq(schema.participants.sportId, sportId),
|
||||
eq(schema.participants.name, row.name),
|
||||
),
|
||||
});
|
||||
let canonicalId = existing?.id;
|
||||
if (!canonicalId) {
|
||||
const [created] = await db
|
||||
.insert(schema.participants)
|
||||
.values({ sportId, name: row.name })
|
||||
.returning();
|
||||
canonicalId = created.id;
|
||||
}
|
||||
enriched.push({ ...row, participantId: canonicalId });
|
||||
}
|
||||
|
||||
return await db
|
||||
.insert(schema.seasonParticipants)
|
||||
.values(data)
|
||||
.values(enriched)
|
||||
.returning();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@
|
|||
*/
|
||||
|
||||
import { database } from "~/database/context";
|
||||
import { seasonParticipantSurfaceElos, seasonParticipants } from "~/database/schema";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { participantSurfaceElos, seasonParticipantSurfaceElos, seasonParticipants } from "~/database/schema";
|
||||
import { eq, inArray, sql } from "drizzle-orm";
|
||||
|
||||
export type CourtSurface = "hard" | "clay" | "grass";
|
||||
|
||||
|
|
@ -99,28 +99,80 @@ export async function batchUpsertSurfaceElos(
|
|||
updatedAt: sql`excluded.updated_at`,
|
||||
},
|
||||
});
|
||||
|
||||
// Mirror writes to the canonical participant_surface_elos table.
|
||||
// The simulator reads from canonical, so per-window-only edits would
|
||||
// not affect simulations. Remove the per-window path entirely in Phase 4.
|
||||
const sps = await db
|
||||
.select({ id: seasonParticipants.id, canonicalId: seasonParticipants.participantId })
|
||||
.from(seasonParticipants)
|
||||
.where(inArray(seasonParticipants.id, inputs.map((i) => i.participantId)));
|
||||
const canonicalByInputId = new Map(sps.map((sp) => [sp.id, sp.canonicalId]));
|
||||
|
||||
const canonicalRows = inputs
|
||||
.map((i) => ({
|
||||
canonicalId: canonicalByInputId.get(i.participantId),
|
||||
worldRanking: i.worldRanking ?? null,
|
||||
eloHard: i.eloHard ?? null,
|
||||
eloClay: i.eloClay ?? null,
|
||||
eloGrass: i.eloGrass ?? null,
|
||||
}))
|
||||
.filter((r): r is typeof r & { canonicalId: string } => typeof r.canonicalId === "string");
|
||||
|
||||
if (canonicalRows.length === 0) return;
|
||||
|
||||
await db
|
||||
.insert(participantSurfaceElos)
|
||||
.values(canonicalRows.map(({ canonicalId, ...rest }) => ({
|
||||
participantId: canonicalId,
|
||||
...rest,
|
||||
updatedAt: now,
|
||||
})))
|
||||
.onConflictDoUpdate({
|
||||
target: [participantSurfaceElos.participantId],
|
||||
set: {
|
||||
worldRanking: sql`excluded.world_ranking`,
|
||||
eloHard: sql`excluded.elo_hard`,
|
||||
eloClay: sql`excluded.elo_clay`,
|
||||
eloGrass: sql`excluded.elo_grass`,
|
||||
updatedAt: sql`excluded.updated_at`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Map from participantId to surface Elos for use in the simulator.
|
||||
* Participants with no record are absent from the map (simulator falls back to 1500).
|
||||
*/
|
||||
/**
|
||||
* Build a map keyed by seasonParticipant.id → surface Elo values, sourced from
|
||||
* the canonical `participant_surface_elos` table. Joins seasonParticipants →
|
||||
* canonical participants → canonical surfaceElo.
|
||||
*
|
||||
* Season participants without a linked canonical participant, or with no
|
||||
* canonical surface Elo row, are simply absent from the map — callers should
|
||||
* handle `undefined` for such cases (as the tennis simulator does).
|
||||
*/
|
||||
export async function getSurfaceEloMap(
|
||||
sportsSeasonId: string
|
||||
): Promise<Map<string, { worldRanking: number | null; eloHard: number | null; eloClay: number | null; eloGrass: number | null }>> {
|
||||
const db = database();
|
||||
const rows = await db
|
||||
.select({
|
||||
participantId: seasonParticipantSurfaceElos.participantId,
|
||||
worldRanking: seasonParticipantSurfaceElos.worldRanking,
|
||||
eloHard: seasonParticipantSurfaceElos.eloHard,
|
||||
eloClay: seasonParticipantSurfaceElos.eloClay,
|
||||
eloGrass: seasonParticipantSurfaceElos.eloGrass,
|
||||
seasonParticipantId: seasonParticipants.id,
|
||||
worldRanking: participantSurfaceElos.worldRanking,
|
||||
eloHard: participantSurfaceElos.eloHard,
|
||||
eloClay: participantSurfaceElos.eloClay,
|
||||
eloGrass: participantSurfaceElos.eloGrass,
|
||||
})
|
||||
.from(seasonParticipantSurfaceElos)
|
||||
.where(eq(seasonParticipantSurfaceElos.sportsSeasonId, sportsSeasonId));
|
||||
.from(seasonParticipants)
|
||||
.innerJoin(
|
||||
participantSurfaceElos,
|
||||
eq(participantSurfaceElos.participantId, seasonParticipants.participantId),
|
||||
)
|
||||
.where(eq(seasonParticipants.sportsSeasonId, sportsSeasonId));
|
||||
|
||||
return new Map(rows.map((r) => [r.participantId, {
|
||||
return new Map(rows.map((r) => [r.seasonParticipantId, {
|
||||
worldRanking: r.worldRanking,
|
||||
eloHard: r.eloHard,
|
||||
eloClay: r.eloClay,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { eq, and, asc } from "drizzle-orm";
|
||||
import { eq, and, asc, desc } from "drizzle-orm";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
|
||||
|
|
@ -80,6 +80,50 @@ export async function upsertTournament(
|
|||
return await createTournament(data);
|
||||
}
|
||||
|
||||
export async function findAllTournamentsGroupedBySport(): Promise<
|
||||
Array<{
|
||||
sport: { id: string; name: string };
|
||||
tournaments: Tournament[];
|
||||
}>
|
||||
> {
|
||||
const db = database();
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
tournament: schema.tournaments,
|
||||
sport: {
|
||||
id: schema.sports.id,
|
||||
name: schema.sports.name,
|
||||
},
|
||||
})
|
||||
.from(schema.tournaments)
|
||||
.innerJoin(schema.sports, eq(schema.tournaments.sportId, schema.sports.id))
|
||||
.orderBy(
|
||||
asc(schema.sports.name),
|
||||
desc(schema.tournaments.year),
|
||||
asc(schema.tournaments.startsAt)
|
||||
);
|
||||
|
||||
const groupMap = new Map<
|
||||
string,
|
||||
{ sport: { id: string; name: string }; tournaments: Tournament[] }
|
||||
>();
|
||||
|
||||
for (const row of rows) {
|
||||
const existing = groupMap.get(row.sport.id);
|
||||
if (existing) {
|
||||
existing.tournaments.push(row.tournament);
|
||||
} else {
|
||||
groupMap.set(row.sport.id, {
|
||||
sport: row.sport,
|
||||
tournaments: [row.tournament],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(groupMap.values());
|
||||
}
|
||||
|
||||
export async function updateTournamentStatus(
|
||||
id: string,
|
||||
status: TournamentStatus
|
||||
|
|
|
|||
|
|
@ -129,6 +129,8 @@ export default [
|
|||
"routes/admin.sports-seasons.$id.clone.tsx"
|
||||
),
|
||||
route("participants", "routes/admin.participants.tsx"),
|
||||
route("tournaments", "routes/admin.tournaments._index.tsx"),
|
||||
route("tournaments/:id", "routes/admin.tournaments.$id.tsx"),
|
||||
route("templates", "routes/admin.templates.tsx"),
|
||||
route("templates/new", "routes/admin.templates.new.tsx"),
|
||||
route("templates/:id", "routes/admin.templates.$id.tsx"),
|
||||
|
|
|
|||
182
app/routes/__tests__/admin.tournaments.$id.test.tsx
Normal file
182
app/routes/__tests__/admin.tournaments.$id.test.tsx
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("~/lib/auth.server", () => ({
|
||||
auth: { api: { getSession: vi.fn() } },
|
||||
}));
|
||||
vi.mock("~/models/user", () => ({
|
||||
isUserAdmin: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/tournament", () => ({
|
||||
getTournamentById: vi.fn(),
|
||||
updateTournamentStatus: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/tournament-result", () => ({
|
||||
getTournamentResults: vi.fn(),
|
||||
upsertTournamentResult: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/participant", () => ({
|
||||
findCanonicalParticipantsBySport: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/services/sync-tournament-results", () => ({
|
||||
syncTournamentResults: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/lib/logger", () => ({
|
||||
logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
import { action } from "~/routes/admin.tournaments.$id";
|
||||
import { auth } from "~/lib/auth.server";
|
||||
import { isUserAdmin } from "~/models/user";
|
||||
import {
|
||||
getTournamentById,
|
||||
updateTournamentStatus,
|
||||
} from "~/models/tournament";
|
||||
import { upsertTournamentResult } from "~/models/tournament-result";
|
||||
import { syncTournamentResults } from "~/services/sync-tournament-results";
|
||||
|
||||
const TOURNAMENT_ID = "tournament-1";
|
||||
const ADMIN_USER_ID = "admin-1";
|
||||
const NON_ADMIN_USER_ID = "user-1";
|
||||
|
||||
function makeRequest(body: Record<string, string>) {
|
||||
const formData = new FormData();
|
||||
for (const [k, v] of Object.entries(body)) {
|
||||
formData.append(k, v);
|
||||
}
|
||||
return new Request(`http://localhost/admin/tournaments/${TOURNAMENT_ID}`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
}
|
||||
|
||||
function makeActionArgs(request: Request) {
|
||||
return {
|
||||
request,
|
||||
params: { id: TOURNAMENT_ID },
|
||||
context: {} as never,
|
||||
} as unknown as Parameters<typeof action>[0];
|
||||
}
|
||||
|
||||
describe("admin.tournaments.$id action", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("batch-upsert-results: upserts each row, marks tournament completed, and fans out", async () => {
|
||||
vi.mocked(auth.api.getSession).mockResolvedValue({
|
||||
user: { id: ADMIN_USER_ID },
|
||||
} as never);
|
||||
vi.mocked(isUserAdmin).mockResolvedValue(true);
|
||||
vi.mocked(getTournamentById).mockResolvedValue({
|
||||
id: TOURNAMENT_ID,
|
||||
sportId: "sport-1",
|
||||
name: "US Open",
|
||||
year: 2026,
|
||||
status: "scheduled",
|
||||
} as never);
|
||||
vi.mocked(upsertTournamentResult).mockResolvedValue({} as never);
|
||||
vi.mocked(updateTournamentStatus).mockResolvedValue({} as never);
|
||||
const syncReport = {
|
||||
tournamentId: TOURNAMENT_ID,
|
||||
windowsSynced: 2,
|
||||
windowsFailed: 0,
|
||||
failures: [],
|
||||
};
|
||||
vi.mocked(syncTournamentResults).mockResolvedValue(syncReport);
|
||||
|
||||
const results = [
|
||||
{ participantId: "p-1", placement: 1 },
|
||||
{ participantId: "p-2", placement: 2 },
|
||||
{ participantId: "p-3", placement: 3 },
|
||||
];
|
||||
const request = makeRequest({
|
||||
intent: "batch-upsert-results",
|
||||
results: JSON.stringify(results),
|
||||
});
|
||||
|
||||
const response = await action(makeActionArgs(request));
|
||||
|
||||
expect(upsertTournamentResult).toHaveBeenCalledTimes(3);
|
||||
expect(upsertTournamentResult).toHaveBeenCalledWith({
|
||||
tournamentId: TOURNAMENT_ID,
|
||||
participantId: "p-1",
|
||||
placement: 1,
|
||||
});
|
||||
expect(updateTournamentStatus).toHaveBeenCalledWith(
|
||||
TOURNAMENT_ID,
|
||||
"completed"
|
||||
);
|
||||
expect(syncTournamentResults).toHaveBeenCalledWith(TOURNAMENT_ID);
|
||||
expect(response).toMatchObject({
|
||||
success: true,
|
||||
syncReport,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not re-update status when tournament is already completed", async () => {
|
||||
vi.mocked(auth.api.getSession).mockResolvedValue({
|
||||
user: { id: ADMIN_USER_ID },
|
||||
} as never);
|
||||
vi.mocked(isUserAdmin).mockResolvedValue(true);
|
||||
vi.mocked(getTournamentById).mockResolvedValue({
|
||||
id: TOURNAMENT_ID,
|
||||
sportId: "sport-1",
|
||||
name: "US Open",
|
||||
year: 2026,
|
||||
status: "completed",
|
||||
} as never);
|
||||
vi.mocked(upsertTournamentResult).mockResolvedValue({} as never);
|
||||
vi.mocked(syncTournamentResults).mockResolvedValue({
|
||||
tournamentId: TOURNAMENT_ID,
|
||||
windowsSynced: 1,
|
||||
windowsFailed: 0,
|
||||
failures: [],
|
||||
});
|
||||
|
||||
const request = makeRequest({
|
||||
intent: "batch-upsert-results",
|
||||
results: JSON.stringify([{ participantId: "p-1", placement: 1 }]),
|
||||
});
|
||||
|
||||
await action(makeActionArgs(request));
|
||||
|
||||
expect(updateTournamentStatus).not.toHaveBeenCalled();
|
||||
expect(syncTournamentResults).toHaveBeenCalledWith(TOURNAMENT_ID);
|
||||
});
|
||||
|
||||
it("non-admin user is forbidden (403)", async () => {
|
||||
vi.mocked(auth.api.getSession).mockResolvedValue({
|
||||
user: { id: NON_ADMIN_USER_ID },
|
||||
} as never);
|
||||
vi.mocked(isUserAdmin).mockResolvedValue(false);
|
||||
|
||||
const request = makeRequest({
|
||||
intent: "batch-upsert-results",
|
||||
results: JSON.stringify([{ participantId: "p-1", placement: 1 }]),
|
||||
});
|
||||
|
||||
await expect(action(makeActionArgs(request))).rejects.toMatchObject({
|
||||
status: 403,
|
||||
});
|
||||
|
||||
expect(upsertTournamentResult).not.toHaveBeenCalled();
|
||||
expect(syncTournamentResults).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("missing tournament returns 404", async () => {
|
||||
vi.mocked(auth.api.getSession).mockResolvedValue({
|
||||
user: { id: ADMIN_USER_ID },
|
||||
} as never);
|
||||
vi.mocked(isUserAdmin).mockResolvedValue(true);
|
||||
vi.mocked(getTournamentById).mockResolvedValue(null);
|
||||
|
||||
const request = makeRequest({
|
||||
intent: "batch-upsert-results",
|
||||
results: JSON.stringify([{ participantId: "p-1", placement: 1 }]),
|
||||
});
|
||||
|
||||
await expect(action(makeActionArgs(request))).rejects.toMatchObject({
|
||||
status: 404,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -27,6 +27,8 @@ import { getQPStandings, getQPConfig } from "~/models/qualifying-points";
|
|||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { upsertTournamentResult } from "~/models/tournament-result";
|
||||
import { syncTournamentResults } from "~/services/sync-tournament-results";
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
const sportsSeason = await findSportsSeasonById(params.id);
|
||||
|
|
@ -372,6 +374,46 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
}
|
||||
|
||||
try {
|
||||
// If this scoring event is linked to a canonical tournament, write
|
||||
// results to the canonical layer and fan out via syncTournamentResults.
|
||||
// This keeps sibling windows (if any) in sync automatically.
|
||||
const scoringEvent = await database().query.scoringEvents.findFirst({
|
||||
where: eq(schema.scoringEvents.id, params.eventId),
|
||||
});
|
||||
if (!scoringEvent) {
|
||||
return { error: "Scoring event not found" };
|
||||
}
|
||||
|
||||
if (scoringEvent.tournamentId) {
|
||||
// Canonical path: translate season_participant.id → canonical participant.id
|
||||
const spRows = allSeasonParticipants.filter((sp) =>
|
||||
incoming.some((r) => r.participantId === sp.id),
|
||||
);
|
||||
const missingCanonical = spRows.filter((sp) => !sp.participantId);
|
||||
if (missingCanonical.length > 0) {
|
||||
return {
|
||||
error: `Some season participants are not linked to canonical participants: ${missingCanonical.map((sp) => sp.name).join(", ")}`,
|
||||
};
|
||||
}
|
||||
|
||||
for (const row of incoming) {
|
||||
const sp = spRows.find((s) => s.id === row.participantId);
|
||||
if (!sp?.participantId) continue;
|
||||
await upsertTournamentResult({
|
||||
tournamentId: scoringEvent.tournamentId,
|
||||
participantId: sp.participantId,
|
||||
placement: row.placement,
|
||||
});
|
||||
}
|
||||
|
||||
const syncReport = await syncTournamentResults(scoringEvent.tournamentId);
|
||||
return {
|
||||
success: `${incoming.length} result${incoming.length === 1 ? "" : "s"} saved canonically. Synced to ${syncReport.windowsSynced} window${syncReport.windowsSynced === 1 ? "" : "s"}${syncReport.windowsFailed > 0 ? ` (${syncReport.windowsFailed} failed)` : ""}.`,
|
||||
syncReport,
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy direct-write path for non-canonical events (team sports, etc.).
|
||||
const existingResults = await getEventResults(params.eventId);
|
||||
const existingIds = new Set(existingResults.map((r) => r.seasonParticipantId));
|
||||
const newResults = filterNewResults(incoming, existingIds);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import {
|
|||
} from "~/models/scoring-event";
|
||||
import { getQPStandings } from "~/models/qualifying-points";
|
||||
import { finalizeQualifyingPoints } from "~/models/scoring-calculator";
|
||||
import { upsertTournament } from "~/models/tournament";
|
||||
import { extractTournamentIdentity } from "~/lib/tournament-identity";
|
||||
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
|
|
@ -107,6 +109,31 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
return { error: "No valid events found. Use format: Event Name, YYYY-MM-DD" };
|
||||
}
|
||||
|
||||
// Auto-provision canonical tournaments for qualifying events (check constraint).
|
||||
try {
|
||||
for (const ev of validEvents) {
|
||||
if (!ev.isQualifyingEvent) continue;
|
||||
const identity = extractTournamentIdentity({
|
||||
name: ev.name,
|
||||
eventDate: ev.eventDate ? ev.eventDate.toISOString().slice(0, 10) : null,
|
||||
eventType: ev.eventType,
|
||||
});
|
||||
const tournament = await upsertTournament({
|
||||
sportId: sportsSeason.sportId,
|
||||
name: identity.name,
|
||||
year: identity.year,
|
||||
startsAt: ev.eventStartsAt ?? null,
|
||||
});
|
||||
(ev as typeof ev & { tournamentId: string }).tournamentId = tournament.id;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("Error linking qualifying events to canonical tournaments:", err);
|
||||
return {
|
||||
error:
|
||||
"Could not determine tournament identity for one or more qualifying events. Include a 4-digit year or date for each.",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await bulkCreateScoringEvents(params.id, validEvents);
|
||||
return { success: `Created ${validEvents.length} event${validEvents.length !== 1 ? "s" : ""} successfully` };
|
||||
|
|
@ -178,6 +205,31 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
isQualifyingEvent,
|
||||
};
|
||||
|
||||
// Qualifying events require a canonical tournament_id (check constraint).
|
||||
// Auto-provision one by extracting (name, year) from the event name/date.
|
||||
if (isQualifyingEvent) {
|
||||
try {
|
||||
const identity = extractTournamentIdentity({
|
||||
name: eventData.name,
|
||||
eventDate: eventDate ? eventDate.toISOString().slice(0, 10) : null,
|
||||
eventType,
|
||||
});
|
||||
const tournament = await upsertTournament({
|
||||
sportId: sportsSeason.sportId,
|
||||
name: identity.name,
|
||||
year: identity.year,
|
||||
startsAt: eventStartsAt ?? null,
|
||||
});
|
||||
eventData.tournamentId = tournament.id;
|
||||
} catch (err) {
|
||||
logger.error("Error linking qualifying event to canonical tournament:", err);
|
||||
return {
|
||||
error:
|
||||
"Could not determine tournament identity. Include a 4-digit year in the event name, or set a date.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const event = await createScoringEvent(eventData);
|
||||
return redirect(`/admin/sports-seasons/${params.id}/events/${event.id}`);
|
||||
|
|
|
|||
353
app/routes/admin.tournaments.$id.tsx
Normal file
353
app/routes/admin.tournaments.$id.tsx
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
import { Link, useFetcher } from "react-router";
|
||||
import { auth } from "~/lib/auth.server";
|
||||
import type { Route } from "./+types/admin.tournaments.$id";
|
||||
|
||||
import { logger } from "~/lib/logger";
|
||||
import {
|
||||
getTournamentById,
|
||||
updateTournamentStatus,
|
||||
} from "~/models/tournament";
|
||||
import {
|
||||
getTournamentResults,
|
||||
upsertTournamentResult,
|
||||
} from "~/models/tournament-result";
|
||||
import { findCanonicalParticipantsBySport } from "~/models/participant";
|
||||
import { isUserAdmin } from "~/models/user";
|
||||
import {
|
||||
syncTournamentResults,
|
||||
type SyncReport,
|
||||
} from "~/services/sync-tournament-results";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "~/components/ui/table";
|
||||
import { ArrowLeft, CheckCircle2, AlertTriangle } from "lucide-react";
|
||||
import { BatchResultEntry } from "~/components/BatchResultEntry";
|
||||
|
||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||
return [
|
||||
{
|
||||
title: `${data?.tournament?.name ?? "Tournament"} - Brackt Admin`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
const tournament = await getTournamentById(params.id);
|
||||
if (!tournament) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const [results, canonicalParticipants] = await Promise.all([
|
||||
getTournamentResults(tournament.id),
|
||||
findCanonicalParticipantsBySport(tournament.sportId),
|
||||
]);
|
||||
|
||||
return { tournament, results, canonicalParticipants };
|
||||
}
|
||||
|
||||
export async function action(args: Route.ActionArgs) {
|
||||
const { request, params } = args;
|
||||
const session = await auth.api.getSession({ headers: request.headers });
|
||||
const userId = session?.user.id ?? null;
|
||||
const isAdmin = userId ? await isUserAdmin(userId) : false;
|
||||
if (!isAdmin) {
|
||||
throw new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
const tournament = await getTournamentById(params.id);
|
||||
if (!tournament) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const intent = formData.get("intent");
|
||||
|
||||
if (intent === "batch-upsert-results") {
|
||||
const resultsRaw = formData.get("results");
|
||||
if (typeof resultsRaw !== "string" || !resultsRaw) {
|
||||
return {
|
||||
success: false as const,
|
||||
error: "Missing results payload",
|
||||
syncReport: null,
|
||||
};
|
||||
}
|
||||
|
||||
let parsed: Array<{ participantId: string; placement: number }>;
|
||||
try {
|
||||
parsed = JSON.parse(resultsRaw);
|
||||
} catch {
|
||||
return {
|
||||
success: false as const,
|
||||
error: "Invalid JSON in results payload",
|
||||
syncReport: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
return {
|
||||
success: false as const,
|
||||
error: "Results payload must be an array",
|
||||
syncReport: null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
for (const row of parsed) {
|
||||
if (
|
||||
!row ||
|
||||
typeof row.participantId !== "string" ||
|
||||
typeof row.placement !== "number"
|
||||
) {
|
||||
return {
|
||||
success: false as const,
|
||||
error: "Each result must have participantId and placement",
|
||||
syncReport: null,
|
||||
};
|
||||
}
|
||||
await upsertTournamentResult({
|
||||
tournamentId: tournament.id,
|
||||
participantId: row.participantId,
|
||||
placement: row.placement,
|
||||
});
|
||||
}
|
||||
|
||||
if (tournament.status !== "completed") {
|
||||
await updateTournamentStatus(tournament.id, "completed");
|
||||
}
|
||||
|
||||
const syncReport = await syncTournamentResults(tournament.id);
|
||||
return {
|
||||
success: true as const,
|
||||
error: null,
|
||||
syncReport,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("batch-upsert-results failed:", error);
|
||||
return {
|
||||
success: false as const,
|
||||
error:
|
||||
error instanceof Error ? error.message : "Failed to save results",
|
||||
syncReport: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (intent === "retry-window-sync") {
|
||||
try {
|
||||
const syncReport = await syncTournamentResults(tournament.id);
|
||||
return { success: true as const, error: null, syncReport };
|
||||
} catch (error) {
|
||||
logger.error("retry-window-sync failed:", error);
|
||||
return {
|
||||
success: false as const,
|
||||
error:
|
||||
error instanceof Error ? error.message : "Failed to retry sync",
|
||||
syncReport: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false as const,
|
||||
error: "Invalid intent",
|
||||
syncReport: null,
|
||||
};
|
||||
}
|
||||
|
||||
export default function AdminTournamentDetail({
|
||||
loaderData,
|
||||
actionData,
|
||||
}: Route.ComponentProps) {
|
||||
const { tournament, results, canonicalParticipants } = loaderData;
|
||||
const retryFetcher = useFetcher<typeof action>();
|
||||
|
||||
// Prefer the latest action/retry response for the sync report
|
||||
const liveReport: SyncReport | null =
|
||||
(retryFetcher.data?.syncReport ?? actionData?.syncReport) ?? null;
|
||||
|
||||
const participantById = new Map(
|
||||
canonicalParticipants.map((p) => [p.id, p])
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="max-w-5xl">
|
||||
<div className="mb-6">
|
||||
<Button variant="ghost" size="sm" asChild className="mb-2">
|
||||
<Link to="/admin/tournaments">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to tournaments
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold">{tournament.name}</h1>
|
||||
<Badge
|
||||
variant={
|
||||
tournament.status === "completed"
|
||||
? "default"
|
||||
: tournament.status === "in_progress"
|
||||
? "secondary"
|
||||
: "outline"
|
||||
}
|
||||
>
|
||||
{tournament.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{tournament.year}
|
||||
{tournament.location ? ` — ${tournament.location}` : ""}
|
||||
{tournament.surface ? ` — ${tournament.surface}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{liveReport && (
|
||||
<Card className="mb-6 border-emerald-500/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-emerald-500">
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
Synced to {liveReport.windowsSynced}{" "}
|
||||
{liveReport.windowsSynced === 1 ? "window" : "windows"}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Canonical results were fanned out to every linked scoring
|
||||
window.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{liveReport.failures.length > 0 && (
|
||||
<CardContent>
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 text-destructive font-medium">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{liveReport.failures.length}{" "}
|
||||
{liveReport.failures.length === 1 ? "window" : "windows"}{" "}
|
||||
failed to sync
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{liveReport.failures.map((f) => (
|
||||
<div
|
||||
key={f.scoringEventId}
|
||||
className="flex items-start justify-between gap-4 text-sm"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="font-mono text-xs text-muted-foreground">
|
||||
event: {f.scoringEventId}
|
||||
</div>
|
||||
<div className="font-mono text-xs text-muted-foreground">
|
||||
season: {f.sportsSeasonId}
|
||||
</div>
|
||||
<div className="text-destructive">{f.error}</div>
|
||||
</div>
|
||||
<retryFetcher.Form method="post">
|
||||
<input
|
||||
type="hidden"
|
||||
name="intent"
|
||||
value="retry-window-sync"
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="scoringEventId"
|
||||
value={f.scoringEventId}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={retryFetcher.state !== "idle"}
|
||||
>
|
||||
{retryFetcher.state !== "idle"
|
||||
? "Retrying…"
|
||||
: "Retry"}
|
||||
</Button>
|
||||
</retryFetcher.Form>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{actionData?.error && (
|
||||
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm mb-6">
|
||||
{actionData.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Current Results</CardTitle>
|
||||
<CardDescription>
|
||||
{results.length}{" "}
|
||||
{results.length === 1 ? "result" : "results"} recorded
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{results.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-6 text-center">
|
||||
No results recorded yet. Paste a ranked list to the right to
|
||||
import.
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-24">Placement</TableHead>
|
||||
<TableHead>Participant</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{results.map((r) => {
|
||||
const p = participantById.get(r.participantId);
|
||||
return (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="font-semibold">
|
||||
{r.placement ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{p?.name ?? (
|
||||
<span className="text-muted-foreground font-mono text-xs">
|
||||
{r.participantId}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<BatchResultEntry
|
||||
participants={canonicalParticipants.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
}))}
|
||||
sportsSeasonId=""
|
||||
existingResultParticipantIds={
|
||||
new Set(results.map((r) => r.participantId))
|
||||
}
|
||||
intent="batch-upsert-results"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
138
app/routes/admin.tournaments._index.tsx
Normal file
138
app/routes/admin.tournaments._index.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { Link } from "react-router";
|
||||
import type { Route } from "./+types/admin.tournaments._index";
|
||||
|
||||
import { findAllTournamentsGroupedBySport } from "~/models/tournament";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "~/components/ui/table";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Trophy } from "lucide-react";
|
||||
|
||||
export function meta(): Route.MetaDescriptors {
|
||||
return [{ title: "Tournaments - Brackt Admin" }];
|
||||
}
|
||||
|
||||
export async function loader() {
|
||||
const grouped = await findAllTournamentsGroupedBySport();
|
||||
return { grouped };
|
||||
}
|
||||
|
||||
export default function AdminTournamentsIndex({
|
||||
loaderData,
|
||||
}: Route.ComponentProps) {
|
||||
const { grouped } = loaderData;
|
||||
|
||||
const totalCount = grouped.reduce(
|
||||
(acc, g) => acc + g.tournaments.length,
|
||||
0
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Tournaments</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Canonical tournaments across all sports. Enter results once here
|
||||
and they fan out to every linked scoring window.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{grouped.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<Trophy className="mx-auto h-12 w-12 text-muted-foreground" />
|
||||
<h3 className="mt-4 text-lg font-semibold">
|
||||
No tournaments yet
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Canonical tournaments are created automatically by the backfill
|
||||
or data-sync flows.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{totalCount} {totalCount === 1 ? "tournament" : "tournaments"}{" "}
|
||||
across {grouped.length}{" "}
|
||||
{grouped.length === 1 ? "sport" : "sports"}
|
||||
</p>
|
||||
{grouped.map((group) => (
|
||||
<Card key={group.sport.id}>
|
||||
<CardHeader>
|
||||
<CardTitle>{group.sport.name}</CardTitle>
|
||||
<CardDescription>
|
||||
{group.tournaments.length}{" "}
|
||||
{group.tournaments.length === 1
|
||||
? "tournament"
|
||||
: "tournaments"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Year</TableHead>
|
||||
<TableHead>Starts</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{group.tournaments.map((t) => (
|
||||
<TableRow key={t.id}>
|
||||
<TableCell className="font-medium">{t.name}</TableCell>
|
||||
<TableCell>{t.year}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{t.startsAt
|
||||
? new Date(t.startsAt).toLocaleDateString()
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
t.status === "completed"
|
||||
? "default"
|
||||
: t.status === "in_progress"
|
||||
? "secondary"
|
||||
: "outline"
|
||||
}
|
||||
>
|
||||
{t.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to={`/admin/tournaments/${t.id}`}>
|
||||
Open
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
578
app/services/__tests__/sync-tournament-results.test.ts
Normal file
578
app/services/__tests__/sync-tournament-results.test.ts
Normal file
|
|
@ -0,0 +1,578 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("~/database/context", () => ({
|
||||
database: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("~/models/scoring-calculator", () => ({
|
||||
processQualifyingEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
import { syncTournamentResults } from "../sync-tournament-results";
|
||||
import { database } from "~/database/context";
|
||||
import { processQualifyingEvent } from "~/models/scoring-calculator";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock data types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FakeTournamentResult {
|
||||
tournamentId: string;
|
||||
participantId: string;
|
||||
placement: number | null;
|
||||
rawScore: string | null;
|
||||
}
|
||||
|
||||
interface FakeScoringEvent {
|
||||
id: string;
|
||||
sportsSeasonId: string;
|
||||
tournamentId: string | null;
|
||||
}
|
||||
|
||||
interface FakeSeasonParticipant {
|
||||
id: string;
|
||||
sportsSeasonId: string;
|
||||
participantId: string | null;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface FakeEventResult {
|
||||
id: string;
|
||||
scoringEventId: string;
|
||||
seasonParticipantId: string;
|
||||
placement: number | null;
|
||||
qualifyingPointsAwarded: string | null;
|
||||
rawScore: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake in-memory DB
|
||||
//
|
||||
// We build a single shared object with a chainable select/insert/update API
|
||||
// that the service uses identically whether it's the top-level `db` or the
|
||||
// `tx` inside a transaction. This lets us both observe the writes that would
|
||||
// have happened and emulate idempotent upserts.
|
||||
//
|
||||
// Internal state holds the three tables the service queries:
|
||||
// - tournament_results
|
||||
// - scoring_events
|
||||
// - season_participants
|
||||
// - event_results
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FakeDbState {
|
||||
tournamentResults: FakeTournamentResult[];
|
||||
scoringEvents: FakeScoringEvent[];
|
||||
seasonParticipants: FakeSeasonParticipant[];
|
||||
eventResults: FakeEventResult[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A very thin fake drizzle-ish db. The service only uses:
|
||||
* - db.select().from(table).where(cond) -- returns rows
|
||||
* - db.insert(table).values(row[|rows]) -- insert
|
||||
* - db.update(table).set(...).where(cond) -- update
|
||||
* - db.transaction(fn) -- runs fn with the same db
|
||||
*
|
||||
* We identify the target table by reference equality to the schema objects.
|
||||
* The service passes schema.tournamentResults etc., so we capture the real
|
||||
* schema module and compare by ref.
|
||||
*/
|
||||
function makeFakeDb(state: FakeDbState) {
|
||||
// We mirror the shape in a lazy way: each predicate function is stored on the
|
||||
// call so we can match rows. In practice the service only filters by simple
|
||||
// eq() / and() combinations — we don't try to parse drizzle's SQL tree.
|
||||
// Instead, we stash the filters the service applies by table.
|
||||
//
|
||||
// The simpler and more faithful approach: every call records enough info for
|
||||
// us to return the right subset.
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function rowsFor(table: any): any[] {
|
||||
// Table objects are drizzle PgTable objects; we identify them by name.
|
||||
// drizzle PgTables expose `[Symbol.for("drizzle:Name")]` but simpler: we
|
||||
// just check a known property. Schema tables have `._.name`.
|
||||
// Fallback: we compare string representation.
|
||||
const name = tableName(table);
|
||||
switch (name) {
|
||||
case "tournament_results":
|
||||
return state.tournamentResults;
|
||||
case "scoring_events":
|
||||
return state.scoringEvents;
|
||||
case "season_participants":
|
||||
return state.seasonParticipants;
|
||||
case "event_results":
|
||||
return state.eventResults;
|
||||
default:
|
||||
throw new Error(`Unknown table in fake db: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function tableName(table: any): string {
|
||||
// drizzle-orm PgTable exposes the table name under a symbol. Try a few.
|
||||
const sym = Object.getOwnPropertySymbols(table).find(
|
||||
(s) => s.toString() === "Symbol(drizzle:Name)"
|
||||
);
|
||||
if (sym) return table[sym];
|
||||
// Fallback for our shim: read `.tableName` property if set.
|
||||
return table.tableName;
|
||||
}
|
||||
|
||||
const db = {
|
||||
// ---------- select ----------
|
||||
select: vi.fn().mockImplementation(() => {
|
||||
const chain = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
_table: null as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
from(table: any) {
|
||||
chain._table = table;
|
||||
return chain;
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
where(predicate: any) {
|
||||
// The predicate is a drizzle SQL expression; we can't introspect it
|
||||
// easily. The fake predicate stash sets a callback (see below). If
|
||||
// the predicate is a function we apply it; otherwise we return all
|
||||
// rows for the table (the caller will then filter).
|
||||
const rows = rowsFor(chain._table);
|
||||
if (typeof predicate === "function") {
|
||||
return Promise.resolve(rows.filter(predicate));
|
||||
}
|
||||
// Unknown predicate: the test harness attaches `__fakeFilter` to the
|
||||
// predicate value we interposed. When we can't evaluate, just return
|
||||
// all rows — the fake is used only by the service and tests that
|
||||
// control what rows exist.
|
||||
return Promise.resolve(rows);
|
||||
},
|
||||
};
|
||||
return chain;
|
||||
}),
|
||||
|
||||
// ---------- insert ----------
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
insert: vi.fn().mockImplementation((table: any) => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
values(row: any) {
|
||||
const rows = Array.isArray(row) ? row : [row];
|
||||
const existing = rowsFor(table);
|
||||
for (const r of rows) {
|
||||
// Assign a fake id for event_results (others aren't re-read by ID).
|
||||
existing.push({ id: `er-${existing.length + 1}`, ...r });
|
||||
}
|
||||
return {
|
||||
returning: vi.fn().mockResolvedValue(rows),
|
||||
};
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
// ---------- update ----------
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
update: vi.fn().mockImplementation((table: any) => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
set(patch: any) {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
where(predicate: any) {
|
||||
const rows = rowsFor(table);
|
||||
for (const r of rows) {
|
||||
if (typeof predicate === "function" ? predicate(r) : true) {
|
||||
Object.assign(r, patch);
|
||||
}
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
// ---------- transaction ----------
|
||||
transaction: vi
|
||||
.fn()
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.mockImplementation(async (fn: (tx: any) => any) => fn(db)),
|
||||
};
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The service builds drizzle SQL expressions via eq() / and() from
|
||||
// drizzle-orm. We can't intercept those without re-mocking the whole module.
|
||||
// Instead we mock drizzle-orm's eq/and to return predicate functions directly.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock("drizzle-orm", async () => {
|
||||
const actual = await vi.importActual<typeof import("drizzle-orm")>("drizzle-orm");
|
||||
return {
|
||||
...actual,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
eq: (col: any, val: any) => {
|
||||
// Each column object (PgColumn) has a `.name` property giving the DB
|
||||
// column name. Return a predicate that reads that key off the row.
|
||||
const key = colKey(col);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (row: any) => row[key] === val;
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
and: (...preds: any[]) => (row: any) => preds.every((p) => p(row)),
|
||||
};
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function colKey(col: any): string {
|
||||
// drizzle PgColumn: its .name is the snake_case DB name, but our fake state
|
||||
// uses camelCase JS field names. Map common columns manually.
|
||||
const n = col?.name ?? col?.config?.name ?? "";
|
||||
const map: Record<string, string> = {
|
||||
tournament_id: "tournamentId",
|
||||
sports_season_id: "sportsSeasonId",
|
||||
scoring_event_id: "scoringEventId",
|
||||
season_participant_id: "seasonParticipantId",
|
||||
participant_id: "participantId",
|
||||
id: "id",
|
||||
};
|
||||
return map[n] ?? n;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers for building fixture data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function seedBasicState(overrides: Partial<FakeDbState> = {}): FakeDbState {
|
||||
return {
|
||||
tournamentResults: [],
|
||||
scoringEvents: [],
|
||||
seasonParticipants: [],
|
||||
eventResults: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 1: Single window, full roster match
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("syncTournamentResults", () => {
|
||||
it("syncs a single window with two canonical results and fills 0-QP for unmatched", async () => {
|
||||
const state = seedBasicState({
|
||||
tournamentResults: [
|
||||
{
|
||||
tournamentId: "t-1",
|
||||
participantId: "cp-A",
|
||||
placement: 1,
|
||||
rawScore: "280",
|
||||
},
|
||||
{
|
||||
tournamentId: "t-1",
|
||||
participantId: "cp-B",
|
||||
placement: 2,
|
||||
rawScore: "282",
|
||||
},
|
||||
],
|
||||
scoringEvents: [
|
||||
{ id: "ev-1", sportsSeasonId: "ss-1", tournamentId: "t-1" },
|
||||
],
|
||||
seasonParticipants: [
|
||||
{
|
||||
id: "sp-A",
|
||||
sportsSeasonId: "ss-1",
|
||||
participantId: "cp-A",
|
||||
name: "A",
|
||||
},
|
||||
{
|
||||
id: "sp-B",
|
||||
sportsSeasonId: "ss-1",
|
||||
participantId: "cp-B",
|
||||
name: "B",
|
||||
},
|
||||
{
|
||||
id: "sp-C",
|
||||
sportsSeasonId: "ss-1",
|
||||
participantId: "cp-C",
|
||||
name: "C",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const db = makeFakeDb(state);
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
|
||||
|
||||
const report = await syncTournamentResults("t-1");
|
||||
|
||||
expect(report.windowsSynced).toBe(1);
|
||||
expect(report.windowsFailed).toBe(0);
|
||||
expect(report.failures).toEqual([]);
|
||||
|
||||
const eventRows = state.eventResults.filter(
|
||||
(r) => r.scoringEventId === "ev-1"
|
||||
);
|
||||
// sp-A: placement 1, sp-B: placement 2, sp-C: 0-QP filler
|
||||
expect(eventRows).toHaveLength(3);
|
||||
|
||||
const a = eventRows.find((r) => r.seasonParticipantId === "sp-A");
|
||||
expect(a?.placement).toBe(1);
|
||||
expect(a?.rawScore).toBe("280");
|
||||
|
||||
const b = eventRows.find((r) => r.seasonParticipantId === "sp-B");
|
||||
expect(b?.placement).toBe(2);
|
||||
expect(b?.rawScore).toBe("282");
|
||||
|
||||
const c = eventRows.find((r) => r.seasonParticipantId === "sp-C");
|
||||
expect(c?.placement).toBeNull();
|
||||
expect(c?.qualifyingPointsAwarded).toBe("0");
|
||||
|
||||
expect(processQualifyingEvent).toHaveBeenCalledTimes(1);
|
||||
expect(processQualifyingEvent).toHaveBeenCalledWith("ev-1", db);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 2: Two overlapping windows — same tournament, different sports_seasons
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("fans out to multiple windows, each getting only its own participants", async () => {
|
||||
const state = seedBasicState({
|
||||
tournamentResults: [
|
||||
{ tournamentId: "t-1", participantId: "cp-X", placement: 1, rawScore: null },
|
||||
{ tournamentId: "t-1", participantId: "cp-Y", placement: 2, rawScore: null },
|
||||
{ tournamentId: "t-1", participantId: "cp-Z", placement: 3, rawScore: null },
|
||||
],
|
||||
scoringEvents: [
|
||||
{ id: "ev-A", sportsSeasonId: "ss-A", tournamentId: "t-1" },
|
||||
{ id: "ev-B", sportsSeasonId: "ss-B", tournamentId: "t-1" },
|
||||
],
|
||||
seasonParticipants: [
|
||||
// Window A has X, Y
|
||||
{ id: "sp-AX", sportsSeasonId: "ss-A", participantId: "cp-X", name: "X" },
|
||||
{ id: "sp-AY", sportsSeasonId: "ss-A", participantId: "cp-Y", name: "Y" },
|
||||
// Window B has Y, Z
|
||||
{ id: "sp-BY", sportsSeasonId: "ss-B", participantId: "cp-Y", name: "Y" },
|
||||
{ id: "sp-BZ", sportsSeasonId: "ss-B", participantId: "cp-Z", name: "Z" },
|
||||
],
|
||||
});
|
||||
|
||||
const db = makeFakeDb(state);
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
|
||||
|
||||
const report = await syncTournamentResults("t-1");
|
||||
|
||||
expect(report.windowsSynced).toBe(2);
|
||||
expect(report.windowsFailed).toBe(0);
|
||||
|
||||
const aRows = state.eventResults.filter((r) => r.scoringEventId === "ev-A");
|
||||
const bRows = state.eventResults.filter((r) => r.scoringEventId === "ev-B");
|
||||
|
||||
// Window A: sp-AX (placement 1), sp-AY (placement 2). No 0-QP fillers
|
||||
// since both roster members got results.
|
||||
expect(aRows).toHaveLength(2);
|
||||
expect(aRows.find((r) => r.seasonParticipantId === "sp-AX")?.placement).toBe(1);
|
||||
expect(aRows.find((r) => r.seasonParticipantId === "sp-AY")?.placement).toBe(2);
|
||||
|
||||
// Window B: sp-BY (placement 2), sp-BZ (placement 3). Again no fillers.
|
||||
expect(bRows).toHaveLength(2);
|
||||
expect(bRows.find((r) => r.seasonParticipantId === "sp-BY")?.placement).toBe(2);
|
||||
expect(bRows.find((r) => r.seasonParticipantId === "sp-BZ")?.placement).toBe(3);
|
||||
|
||||
expect(processQualifyingEvent).toHaveBeenCalledTimes(2);
|
||||
expect(processQualifyingEvent).toHaveBeenNthCalledWith(1, "ev-A", db);
|
||||
expect(processQualifyingEvent).toHaveBeenNthCalledWith(2, "ev-B", db);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 3: Idempotent re-run
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("is idempotent: a second run leaves the same end state", async () => {
|
||||
const state = seedBasicState({
|
||||
tournamentResults: [
|
||||
{ tournamentId: "t-1", participantId: "cp-A", placement: 1, rawScore: "10" },
|
||||
],
|
||||
scoringEvents: [
|
||||
{ id: "ev-1", sportsSeasonId: "ss-1", tournamentId: "t-1" },
|
||||
],
|
||||
seasonParticipants: [
|
||||
{ id: "sp-A", sportsSeasonId: "ss-1", participantId: "cp-A", name: "A" },
|
||||
{ id: "sp-B", sportsSeasonId: "ss-1", participantId: "cp-B", name: "B" },
|
||||
],
|
||||
});
|
||||
|
||||
const db = makeFakeDb(state);
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
|
||||
|
||||
await syncTournamentResults("t-1");
|
||||
const afterFirst = JSON.parse(JSON.stringify(state.eventResults));
|
||||
|
||||
await syncTournamentResults("t-1");
|
||||
const afterSecond = state.eventResults;
|
||||
|
||||
// Same row count, same per-row placement / QP / participant / event.
|
||||
expect(afterSecond).toHaveLength(afterFirst.length);
|
||||
for (const first of afterFirst) {
|
||||
const match = afterSecond.find(
|
||||
(r) =>
|
||||
r.scoringEventId === first.scoringEventId &&
|
||||
r.seasonParticipantId === first.seasonParticipantId
|
||||
);
|
||||
expect(match).toBeDefined();
|
||||
expect(match?.placement).toBe(first.placement);
|
||||
expect(match?.qualifyingPointsAwarded).toBe(first.qualifyingPointsAwarded);
|
||||
expect(match?.rawScore).toBe(first.rawScore);
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 4: Correction propagation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("propagates canonical placement corrections to all linked windows", async () => {
|
||||
const state = seedBasicState({
|
||||
tournamentResults: [
|
||||
{ tournamentId: "t-1", participantId: "cp-A", placement: 1, rawScore: null },
|
||||
],
|
||||
scoringEvents: [
|
||||
{ id: "ev-A", sportsSeasonId: "ss-A", tournamentId: "t-1" },
|
||||
{ id: "ev-B", sportsSeasonId: "ss-B", tournamentId: "t-1" },
|
||||
],
|
||||
seasonParticipants: [
|
||||
{ id: "sp-AA", sportsSeasonId: "ss-A", participantId: "cp-A", name: "A" },
|
||||
{ id: "sp-BA", sportsSeasonId: "ss-B", participantId: "cp-A", name: "A" },
|
||||
],
|
||||
});
|
||||
|
||||
const db = makeFakeDb(state);
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
|
||||
|
||||
await syncTournamentResults("t-1");
|
||||
|
||||
// Sanity — both windows have sp-* at placement 1.
|
||||
expect(
|
||||
state.eventResults.find((r) => r.seasonParticipantId === "sp-AA")?.placement
|
||||
).toBe(1);
|
||||
expect(
|
||||
state.eventResults.find((r) => r.seasonParticipantId === "sp-BA")?.placement
|
||||
).toBe(1);
|
||||
|
||||
// Correct the canonical placement from 1 to 5.
|
||||
state.tournamentResults[0].placement = 5;
|
||||
state.tournamentResults[0].rawScore = "99";
|
||||
|
||||
await syncTournamentResults("t-1");
|
||||
|
||||
expect(
|
||||
state.eventResults.find((r) => r.seasonParticipantId === "sp-AA")?.placement
|
||||
).toBe(5);
|
||||
expect(
|
||||
state.eventResults.find((r) => r.seasonParticipantId === "sp-AA")?.rawScore
|
||||
).toBe("99");
|
||||
expect(
|
||||
state.eventResults.find((r) => r.seasonParticipantId === "sp-BA")?.placement
|
||||
).toBe(5);
|
||||
expect(
|
||||
state.eventResults.find((r) => r.seasonParticipantId === "sp-BA")?.rawScore
|
||||
).toBe("99");
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 5: Partial failure isolation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("isolates failures: one window's error does not stop the others", async () => {
|
||||
const state = seedBasicState({
|
||||
tournamentResults: [
|
||||
{ tournamentId: "t-1", participantId: "cp-A", placement: 1, rawScore: null },
|
||||
],
|
||||
scoringEvents: [
|
||||
{ id: "ev-A", sportsSeasonId: "ss-A", tournamentId: "t-1" },
|
||||
{ id: "ev-B", sportsSeasonId: "ss-B", tournamentId: "t-1" },
|
||||
],
|
||||
seasonParticipants: [
|
||||
{ id: "sp-AA", sportsSeasonId: "ss-A", participantId: "cp-A", name: "A" },
|
||||
{ id: "sp-BA", sportsSeasonId: "ss-B", participantId: "cp-A", name: "A" },
|
||||
],
|
||||
});
|
||||
|
||||
const db = makeFakeDb(state);
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
|
||||
// First window throws in processQualifyingEvent; second succeeds.
|
||||
vi.mocked(processQualifyingEvent)
|
||||
.mockImplementationOnce(async () => {
|
||||
throw new Error("QP calc failed");
|
||||
})
|
||||
.mockImplementationOnce(async () => undefined);
|
||||
|
||||
const report = await syncTournamentResults("t-1");
|
||||
|
||||
expect(report.windowsSynced).toBe(1);
|
||||
expect(report.windowsFailed).toBe(1);
|
||||
expect(report.failures).toHaveLength(1);
|
||||
expect(report.failures[0].scoringEventId).toBe("ev-A");
|
||||
expect(report.failures[0].sportsSeasonId).toBe("ss-A");
|
||||
expect(report.failures[0].error).toContain("QP calc failed");
|
||||
|
||||
// Second window (ev-B) still has its row committed in our fake (we don't
|
||||
// simulate rollback, but the service called through to the writes path,
|
||||
// which is what we care about — the try/catch did not propagate).
|
||||
expect(
|
||||
state.eventResults.find(
|
||||
(r) => r.scoringEventId === "ev-B" && r.seasonParticipantId === "sp-BA"
|
||||
)
|
||||
).toBeDefined();
|
||||
|
||||
expect(processQualifyingEvent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 6: Participant in roster but not in canonical results → 0-QP filler
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("writes 0-QP filler rows for roster participants absent from canonical results", async () => {
|
||||
const state = seedBasicState({
|
||||
tournamentResults: [
|
||||
{ tournamentId: "t-1", participantId: "cp-A", placement: 1, rawScore: null },
|
||||
],
|
||||
scoringEvents: [
|
||||
{ id: "ev-1", sportsSeasonId: "ss-1", tournamentId: "t-1" },
|
||||
],
|
||||
seasonParticipants: [
|
||||
{ id: "sp-A", sportsSeasonId: "ss-1", participantId: "cp-A", name: "A" },
|
||||
{ id: "sp-B", sportsSeasonId: "ss-1", participantId: "cp-B", name: "B" },
|
||||
{ id: "sp-C", sportsSeasonId: "ss-1", participantId: "cp-C", name: "C" },
|
||||
],
|
||||
});
|
||||
|
||||
const db = makeFakeDb(state);
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
|
||||
|
||||
await syncTournamentResults("t-1");
|
||||
|
||||
const rows = state.eventResults;
|
||||
expect(rows).toHaveLength(3);
|
||||
|
||||
const a = rows.find((r) => r.seasonParticipantId === "sp-A");
|
||||
expect(a?.placement).toBe(1);
|
||||
expect(a?.qualifyingPointsAwarded).toBeUndefined(); // not set by this service
|
||||
|
||||
const b = rows.find((r) => r.seasonParticipantId === "sp-B");
|
||||
expect(b?.placement).toBeNull();
|
||||
expect(b?.qualifyingPointsAwarded).toBe("0");
|
||||
|
||||
const c = rows.find((r) => r.seasonParticipantId === "sp-C");
|
||||
expect(c?.placement).toBeNull();
|
||||
expect(c?.qualifyingPointsAwarded).toBe("0");
|
||||
});
|
||||
});
|
||||
138
app/services/sync-tournament-results.ts
Normal file
138
app/services/sync-tournament-results.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { integer, pgTable, varchar, uuid, timestamp, pgEnum, boolean, text, date, decimal, uniqueIndex, index, jsonb } from "drizzle-orm/pg-core";
|
||||
import { check, integer, pgTable, varchar, uuid, timestamp, pgEnum, boolean, text, date, decimal, uniqueIndex, index, jsonb } from "drizzle-orm/pg-core";
|
||||
import { relations, sql } from "drizzle-orm";
|
||||
|
||||
export const users = pgTable("users", {
|
||||
|
|
@ -554,7 +554,12 @@ export const scoringEvents = pgTable("scoring_events", {
|
|||
completedAt: timestamp("completed_at"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
}, (t) => ({
|
||||
qualifyingEventsRequireTournament: check(
|
||||
"scoring_events_qualifying_require_tournament",
|
||||
sql`NOT ${t.isQualifyingEvent} OR ${t.tournamentId} IS NOT NULL`,
|
||||
),
|
||||
}));
|
||||
|
||||
// Results for participants in specific events
|
||||
export const eventResults = pgTable("event_results", {
|
||||
|
|
@ -575,7 +580,12 @@ export const eventResults = pgTable("event_results", {
|
|||
rawScore: decimal("raw_score", { precision: 10, scale: 2 }), // strokes, points, time, etc.
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
}, (t) => ({
|
||||
uniqueEventParticipant: uniqueIndex("event_results_event_participant_unique").on(
|
||||
t.scoringEventId,
|
||||
t.seasonParticipantId,
|
||||
),
|
||||
}));
|
||||
|
||||
// Playoff bracket matches (for display and tracking)
|
||||
export const playoffMatches = pgTable("playoff_matches", {
|
||||
|
|
|
|||
2
drizzle/0089_lonely_tigra.sql
Normal file
2
drizzle/0089_lonely_tigra.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
CREATE UNIQUE INDEX IF NOT EXISTS "event_results_event_participant_unique" ON "event_results" USING btree ("scoring_event_id","season_participant_id");--> statement-breakpoint
|
||||
ALTER TABLE "scoring_events" ADD CONSTRAINT "scoring_events_qualifying_require_tournament" CHECK (NOT "scoring_events"."is_qualifying_event" OR "scoring_events"."tournament_id" IS NOT NULL);
|
||||
5715
drizzle/meta/0089_snapshot.json
Normal file
5715
drizzle/meta/0089_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -624,6 +624,13 @@
|
|||
"when": 1777666830394,
|
||||
"tag": "0088_cheerful_norrin_radd",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 89,
|
||||
"version": "7",
|
||||
"when": 1777675084016,
|
||||
"tag": "0089_lonely_tigra",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,57 +1,11 @@
|
|||
/**
|
||||
* Pure function(s) for extracting canonical tournament identity
|
||||
* (name + year) from a `scoring_events` row.
|
||||
*
|
||||
* Used by the Phase 2 backfill to group per-window events into
|
||||
* canonical `tournaments` rows.
|
||||
* Re-exports the canonical tournament identity extractor. The implementation
|
||||
* lives in `app/lib/tournament-identity.ts` so it can be shared by the
|
||||
* Phase 2 backfill and by the admin event-creation flow.
|
||||
*/
|
||||
|
||||
export interface ScoringEventInput {
|
||||
name: string;
|
||||
eventDate: string | null;
|
||||
eventType: string;
|
||||
}
|
||||
|
||||
export interface TournamentIdentity {
|
||||
/** Tournament name with any trailing 4-digit year stripped. */
|
||||
name: string;
|
||||
year: number;
|
||||
}
|
||||
|
||||
const TRAILING_YEAR_RE = / (\d{4})$/;
|
||||
|
||||
/**
|
||||
* Extracts the canonical `(name, year)` identity for a tournament
|
||||
* from a scoring event.
|
||||
*
|
||||
* Resolution order for year:
|
||||
* 1. A trailing 4-digit year on the event name (e.g. "Wimbledon 2026").
|
||||
* The year is stripped from the returned name.
|
||||
* 2. The first 4 characters of `eventDate` (format `YYYY-MM-DD`).
|
||||
*
|
||||
* Throws if neither source supplies a year.
|
||||
*/
|
||||
export function extractTournamentIdentity(
|
||||
ev: ScoringEventInput,
|
||||
): TournamentIdentity {
|
||||
const trimmedName = ev.name.trim();
|
||||
const match = trimmedName.match(TRAILING_YEAR_RE);
|
||||
|
||||
if (match) {
|
||||
const yearFromName = Number(match[1]);
|
||||
const nameWithoutYear = trimmedName.slice(0, match.index).trim();
|
||||
return { name: nameWithoutYear, year: yearFromName };
|
||||
}
|
||||
|
||||
if (ev.eventDate) {
|
||||
const yearStr = ev.eventDate.slice(0, 4);
|
||||
const yearFromDate = Number(yearStr);
|
||||
if (Number.isFinite(yearFromDate) && yearStr.length === 4) {
|
||||
return { name: trimmedName, year: yearFromDate };
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`cannot determine year for scoring event "${ev.name}" (eventDate=${ev.eventDate ?? "null"})`,
|
||||
);
|
||||
}
|
||||
export {
|
||||
extractTournamentIdentity,
|
||||
type TournamentIdentity,
|
||||
type ScoringEventIdentityInput as ScoringEventInput,
|
||||
} from "~/lib/tournament-identity";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue