Merge branch 'phase-3-cutover' into canonical-layer-pr2

This commit is contained in:
Chris Parsons 2026-05-01 20:41:39 -07:00
commit 93237ede0a
20 changed files with 7505 additions and 77 deletions

View file

@ -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" });
}

View 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 };
}

View file

@ -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", () => {

View file

@ -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,
}));

View file

@ -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();
}

View file

@ -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,

View file

@ -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

View file

@ -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"),

View 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,
});
});
});

View file

@ -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);

View file

@ -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}`);

View 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>
);
}

View 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>
);
}

View 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");
});
});

View 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;
}

View file

@ -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", {

View 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);

File diff suppressed because it is too large Load diff

View file

@ -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
}
]
}

View file

@ -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";