Canonical tournament layer: schema + backfill (1/2) (#365)

* refactor(schema): rename per-window tables to season_* prefix

Renames participants, participant_expected_values, participant_qualifying_totals,
participant_results, participant_surface_elos to season_* prefixed names.
Renames event_results.participant_id to season_participant_id.
Phase 1a of canonical tournament layer migration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor: rename participant.ts model file to season-participant.ts

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(models): update model layer to use renamed schema exports

Updated all model files to use the renamed schema exports from Task 1:
- participants → seasonParticipants
- participantExpectedValues → seasonParticipantExpectedValues
- participantQualifyingTotals → seasonParticipantQualifyingTotals
- participantResults → seasonParticipantResults
- participantSurfaceElos → seasonParticipantSurfaceElos
- eventResults.participantId → eventResults.seasonParticipantId
- db.query relation accessors updated
- Relation field .participant → .seasonParticipant where applicable
- Import paths updated: ./participant → ./season-participant

Files updated (14 model files + 3 test files):
- draft-pick.ts
- draft-utils.ts
- event-result.ts
- group-stage-match.ts
- participant-result.ts
- qualifying-points.ts
- scoring-calculator.ts
- scoring-event.ts
- sports-season.ts
- surface-elo.ts
- team-score-events.ts
- cs2-major-stage.ts
- golf-skills.ts
- participant-expected-value.ts
- __tests__/sports-season.clone.test.ts
- __tests__/auto-pick.test.ts
- __tests__/executeAutoPick.timer.test.ts

Typecheck errors decreased: 779 → 499 (280 fewer)
All model file errors related to renamed schemas resolved.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(routes): update route layer to use renamed schema exports

- Update model import from ~/models/participant to ~/models/season-participant
- Rename schema.participants to schema.seasonParticipants
- Rename schema.participantResults to schema.seasonParticipantResults
- Rename db.query.participants to db.query.seasonParticipants
- Update 9 route files and 1 test file

Affected files:
- admin.sports-seasons.$id.events.$eventId.bracket.server.ts
- admin.sports-seasons.$id.participants.tsx
- api/draft.force-manual-pick.ts
- api/draft.make-pick.ts
- api/draft.replace-pick.ts
- api/seasons.$seasonId.draft.ts
- leagues/$leagueId.draft-board.$seasonId.tsx
- leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts
- admin/__tests__/sports-seasons-participants.test.ts

Error count reduced from 499 to 453 (46 errors fixed).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(routes): update route files for schema rename

Update route imports from ~/models/participant to ~/models/season-participant
and fix references to .participant/.participantId on event results to use
.seasonParticipant/.seasonParticipantId after schema rename.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(services): update simulators and services for renamed schema

Update all simulators, services, and server files to use renamed schema tables:
- participants → seasonParticipants
- participantExpectedValues → seasonParticipantExpectedValues
- participantResults → seasonParticipantResults
- eventResults.participantId → eventResults.seasonParticipantId

Files updated:
- 20 sport simulators (NBA, NHL, NFL, MLB, etc.)
- probability-updater.ts
- standings-sync/index.ts
- sports-data-sync.server.ts
- server/socket.ts

Typecheck errors reduced from 365 to 0.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* migration: rename per-window tables to season_* prefix

* fix(tests): update mock query keys after participants table rename

Change mock db.query.participants to db.query.seasonParticipants in test
files to match the schema rename from commit 66145a9. This fixes
"Cannot read properties of undefined (reading 'findFirst'/'findMany')"
errors that occurred when production code queries db.query.seasonParticipants
but test mocks only defined the old participants key.

Files updated:
- app/services/simulations/__tests__/world-cup-simulator.test.ts
- app/routes/api/__tests__/draft.force-manual-pick.test.ts
- app/routes/api/__tests__/draft.force-manual-pick.timer-mode.test.ts
- app/routes/api/__tests__/draft.make-pick.timer-mode.test.ts
- server/__tests__/timer-autodraft.test.ts
- app/models/__tests__/team-score-events.test.ts

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(tests): update remaining mock paths and keys after schema rename

* fix(tests): final two mock stragglers after schema rename

- draft-pick.test.ts: assertion on db.query.participantQualifyingTotals
- process-match-result.test.ts: mock key participants → seasonParticipants

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: add post-phase1a baseline capture (temp, for diff verification)

* chore: capture pre-migration baselines

* chore: remove post-phase1a capture helper after verification

* schema: add canonical tournament & participant tables

Adds tournaments, participants (canonical), tournament_results, and
participant_surface_elos (canonical). Adds nullable tournament_id to
scoring_events and nullable participant_id to season_participants.
Phase 1b of canonical tournament layer migration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(models): add canonical tournament, participant, result, surface-elo models

Adds CRUD modules for the canonical tables created in commit 775b905.
Each module mirrors existing app/models conventions (database() from
~/database/context, schema from ~/database/schema, mock-based tests).

Key implementation notes:
- participant.ts exports use "Canonical" prefix (CanonicalParticipant,
  createCanonicalParticipant, etc.) to avoid collision with existing
  season-participant.ts exports
- All four models include comprehensive unit tests following the
  audit-log.test.ts pattern
- Tests use mocked db responses (no real database access)
- Upsert functions use onConflictDoUpdate for appropriate unique constraints

Part of Phase 1b of canonical tournament layer migration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* migration: create canonical tables, add nullable FKs

* scripts: add extractTournamentIdentity helper for backfill

Pure function that derives canonical (name, year) identity from a
scoring_events row, stripping trailing 4-digit years from the name or
falling back to eventDate. Used by the Phase 2 backfill to group
per-window events into canonical tournaments.

* scripts: add backfill orchestrator for canonical layer

Populates canonical tournaments, participants, tournament_results, and
participant_surface_elos from per-window data for qualifying-points
sports. Skips already-linked rows, is idempotent, and supports dry-run
mode.

Critical invariants enforced by the implementation:
- qualifying_points_awarded is never copied to tournament_results
- season_participant_qualifying_totals is never touched
- conflicting surface-Elo values between windows raise a loud error
  (recorded in report.errors) rather than overwriting

* scripts: add backfill CLI with dry-run default

Wires backfill-canonical-layer.ts to a CLI entry point exposed as
`npm run backfill:canonical`. Defaults to --dry-run; requires --apply
to actually write. Supports --sport=<uuid> to limit to a single sport.
Exits 2 if the backfill reports errors (e.g., surface-Elo conflicts).

* fix(backfill-cli): wrap runBackfill in DatabaseContext.run

The orchestrator uses database() from ~/database/context, which requires
AsyncLocalStorage to be populated. Wrap the CLI invocation with
DatabaseContext.run(db, ...) using server/db's cached connection pool.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(backfill-cli): exit 0 on success so pg pool doesn't block

The cached postgres connection pool keeps the Node event loop open after
main() returns. Explicit process.exit(0) on success mirrors the pattern
in scripts/capture-baseline.ts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Chris Parsons <chrisp@extrahop.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-05-01 20:13:18 -07:00 committed by GitHub
parent a068dcc6e7
commit 2848231235
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
94 changed files with 13936 additions and 734 deletions

View file

@ -8,7 +8,7 @@ vi.mock("~/models/draft-pick", () => ({
isParticipantDrafted: vi.fn(),
}));
vi.mock("~/models/participant", () => ({
vi.mock("~/models/season-participant", () => ({
getParticipantsForSeasonWithSports: vi.fn(),
}));
@ -34,7 +34,7 @@ import {
getTeamDraftPicksWithSports,
isParticipantDrafted,
} from "~/models/draft-pick";
import { getParticipantsForSeasonWithSports } from "~/models/participant";
import { getParticipantsForSeasonWithSports } from "~/models/season-participant";
import { getSeasonSportsSimple } from "~/models/season-sport";
import { getTeamQueue, getAllQueuesForSeason } from "~/models/draft-queue";
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
@ -49,7 +49,7 @@ const ALL_TEAM_IDS = [TEAM_ID, "team-2"];
function makeMockDb(overrides: Record<string, unknown> = {}) {
const mockDb: Record<string, unknown> = {
query: {
participants: {
seasonParticipants: {
findMany: vi.fn().mockResolvedValue([]),
},
},
@ -71,7 +71,7 @@ function makeMockDb(overrides: Record<string, unknown> = {}) {
// 3. select().from().where().orderBy() → top participant → [{id, ...}]
function makeMockDbWithEvParticipant(participantId: string) {
return {
query: { participants: { findMany: vi.fn().mockResolvedValue([]) } },
query: { seasonParticipants: { findMany: vi.fn().mockResolvedValue([]) } },
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
innerJoin: vi.fn().mockReturnThis(),
@ -148,7 +148,7 @@ describe("autoPickForTeam queueOnly constraint (AC2 & AC3)", () => {
const mockDb = makeMockDb({
query: {
participants: {
seasonParticipants: {
findMany: vi.fn().mockResolvedValue([
{
id: "p-1",
@ -188,7 +188,7 @@ describe("autoPickForTeam queueOnly constraint (AC2 & AC3)", () => {
const mockWhere = vi.fn().mockResolvedValue(undefined);
const mockDb = makeMockDb({
query: {
participants: {
seasonParticipants: {
findMany: vi.fn().mockResolvedValue([
{
id: "p-1",
@ -233,7 +233,7 @@ describe("autoPickForTeam queueOnly constraint (AC2 & AC3)", () => {
const mockWhere = vi.fn().mockResolvedValue(undefined);
const mockDb = makeMockDb({
query: {
participants: {
seasonParticipants: {
findMany: vi.fn().mockResolvedValue([
{
id: "p-snooker",
@ -280,7 +280,7 @@ describe("autoPickForTeam queueOnly constraint (AC2 & AC3)", () => {
// 4. select().from(participants).where().orderBy() — participant query (chain)
const mockDb = {
query: {
participants: {
seasonParticipants: {
findMany: vi.fn().mockResolvedValue([
{
id: "p-snooker",
@ -330,7 +330,7 @@ describe("autoPickForTeam queueOnly constraint (AC2 & AC3)", () => {
const mockDb = makeMockDb({
query: {
participants: {
seasonParticipants: {
findMany: vi.fn().mockResolvedValue([
{
id: "p-1",
@ -373,7 +373,7 @@ describe("autoPickForTeam queueOnly constraint (AC2 & AC3)", () => {
const mockDb = makeMockDb({
query: {
participants: {
seasonParticipants: {
findMany: vi.fn().mockResolvedValue([
{
id: "p-1",

View file

@ -0,0 +1,100 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
import {
upsertCanonicalSurfaceElo,
getCanonicalSurfaceElo,
} from "../canonical-surface-elo";
import { database } from "~/database/context";
const PARTICIPANT_ID = "participant-1";
const SAMPLE_ELO = {
id: "elo-1",
participantId: PARTICIPANT_ID,
worldRanking: 1,
eloHard: 2400,
eloClay: 2350,
eloGrass: 2300,
updatedAt: new Date("2026-01-28T00:00:00Z"),
};
function makeUpsertDb(returnValue: object) {
return {
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({
onConflictDoUpdate: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([returnValue]),
}),
}),
}),
};
}
function makeSelectDb(rows: object[]) {
return {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue(rows),
}),
}),
}),
};
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("upsertCanonicalSurfaceElo", () => {
it("inserts or updates surface elo data", async () => {
vi.mocked(database).mockReturnValue(makeUpsertDb(SAMPLE_ELO) as never);
const result = await upsertCanonicalSurfaceElo({
participantId: PARTICIPANT_ID,
worldRanking: 1,
eloHard: 2400,
eloClay: 2350,
eloGrass: 2300,
});
expect(result).toEqual(SAMPLE_ELO);
});
it("uses onConflictDoUpdate on participantId", async () => {
const mockDb = makeUpsertDb(SAMPLE_ELO);
vi.mocked(database).mockReturnValue(mockDb as never);
await upsertCanonicalSurfaceElo({
participantId: PARTICIPANT_ID,
worldRanking: 1,
});
const onConflictCall = (mockDb.insert as ReturnType<typeof vi.fn>)
.mock.results[0].value.values.mock.results[0].value.onConflictDoUpdate;
expect(onConflictCall).toHaveBeenCalled();
});
});
describe("getCanonicalSurfaceElo", () => {
it("returns elo data when found", async () => {
vi.mocked(database).mockReturnValue(makeSelectDb([SAMPLE_ELO]) as never);
const result = await getCanonicalSurfaceElo(PARTICIPANT_ID);
expect(result).toEqual(SAMPLE_ELO);
});
it("returns null when not found", async () => {
vi.mocked(database).mockReturnValue(makeSelectDb([]) as never);
const result = await getCanonicalSurfaceElo("nonexistent");
expect(result).toBeNull();
});
});

View file

@ -45,7 +45,7 @@ function makeDb(opts: MakeDbOpts = {}) {
scoringEvents: {
findMany: vi.fn().mockResolvedValue(scoringEvents),
},
participantQualifyingTotals: {
seasonParticipantQualifyingTotals: {
findMany: vi.fn().mockResolvedValue(qualifyingTotals),
},
},
@ -220,6 +220,6 @@ describe("getDraftedParticipantsWithPoints", () => {
await getDraftedParticipantsWithPoints("team-1", "season-1", db);
expect(db.query.participantQualifyingTotals.findMany).not.toHaveBeenCalled();
expect(db.query.seasonParticipantQualifyingTotals.findMany).not.toHaveBeenCalled();
});
});

View file

@ -21,7 +21,7 @@ vi.mock("~/models/draft-pick", () => ({
isParticipantDrafted: vi.fn(),
}));
vi.mock("~/models/participant", () => ({
vi.mock("~/models/season-participant", () => ({
getParticipantsForSeasonWithSports: vi.fn(),
}));
@ -42,7 +42,7 @@ vi.mock("~/lib/draft-eligibility", () => ({
vi.mock("~/database/context");
import { getDraftPicksWithSports, getTeamDraftPicksWithSports, isParticipantDrafted } from "~/models/draft-pick";
import { getParticipantsForSeasonWithSports } from "~/models/participant";
import { getParticipantsForSeasonWithSports } from "~/models/season-participant";
import { getSeasonSportsSimple } from "~/models/season-sport";
import { getTeamQueue, getAllQueuesForSeason } from "~/models/draft-queue";
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
@ -127,7 +127,7 @@ function makeMockDb(seasonOverrides: Record<string, unknown> = {}) {
draftPicks: { findFirst: vi.fn().mockResolvedValue(null) },
seasons: { findFirst: vi.fn().mockResolvedValue(makeSeason(seasonOverrides)) },
draftSlots: { findMany: vi.fn().mockResolvedValue(mockDraftSlots) },
participants: {
seasonParticipants: {
// findMany: used by autoPickForTeam queue path
findMany: vi.fn().mockResolvedValue([mockParticipantForQueue]),
// findFirst: used by executeAutoPick to fetch full participant details

View file

@ -25,8 +25,8 @@ vi.mock("~/database/context", () => ({
}));
vi.mock("~/database/schema", () => ({
participants: { id: "id" },
participantExpectedValues: {
seasonParticipants: { id: "id" },
seasonParticipantExpectedValues: {
participantId: "participantId",
sportsSeasonId: "sportsSeasonId",
},

View file

@ -0,0 +1,152 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
import {
createCanonicalParticipant,
getCanonicalParticipantById,
findCanonicalParticipantsBySport,
findCanonicalParticipantBySportName,
upsertCanonicalParticipantBySportName,
} from "../participant";
import { database } from "~/database/context";
const SPORT_ID = "sport-1";
const PARTICIPANT_ID = "participant-1";
const SAMPLE_PARTICIPANT = {
id: PARTICIPANT_ID,
sportId: SPORT_ID,
name: "Novak Djokovic",
externalKey: "djokovic-n",
metadata: { atp_id: "12345" },
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
};
function makeInsertDb(returnValue: object) {
return {
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([returnValue]),
}),
}),
};
}
function makeSelectDb(rows: object[]) {
return {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue(rows),
orderBy: vi.fn().mockResolvedValue(rows),
}),
}),
}),
};
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("createCanonicalParticipant", () => {
it("inserts a participant and returns it", async () => {
vi.mocked(database).mockReturnValue(makeInsertDb(SAMPLE_PARTICIPANT) as never);
const result = await createCanonicalParticipant({
sportId: SPORT_ID,
name: "Novak Djokovic",
});
expect(result).toEqual(SAMPLE_PARTICIPANT);
});
});
describe("getCanonicalParticipantById", () => {
it("returns the participant when found", async () => {
vi.mocked(database).mockReturnValue(makeSelectDb([SAMPLE_PARTICIPANT]) as never);
const result = await getCanonicalParticipantById(PARTICIPANT_ID);
expect(result).toEqual(SAMPLE_PARTICIPANT);
});
it("returns null when not found", async () => {
vi.mocked(database).mockReturnValue(makeSelectDb([]) as never);
const result = await getCanonicalParticipantById("nonexistent");
expect(result).toBeNull();
});
});
describe("findCanonicalParticipantsBySport", () => {
it("returns participants ordered by name", async () => {
const participants = [SAMPLE_PARTICIPANT];
vi.mocked(database).mockReturnValue(makeSelectDb(participants) as never);
const result = await findCanonicalParticipantsBySport(SPORT_ID);
expect(result).toEqual(participants);
});
});
describe("findCanonicalParticipantBySportName", () => {
it("returns the participant when found", async () => {
vi.mocked(database).mockReturnValue(makeSelectDb([SAMPLE_PARTICIPANT]) as never);
const result = await findCanonicalParticipantBySportName(SPORT_ID, "Novak Djokovic");
expect(result).toEqual(SAMPLE_PARTICIPANT);
});
it("returns null when not found", async () => {
vi.mocked(database).mockReturnValue(makeSelectDb([]) as never);
const result = await findCanonicalParticipantBySportName(SPORT_ID, "Roger Federer");
expect(result).toBeNull();
});
});
describe("upsertCanonicalParticipantBySportName", () => {
it("returns existing participant if found", async () => {
vi.mocked(database).mockReturnValue(makeSelectDb([SAMPLE_PARTICIPANT]) as never);
const result = await upsertCanonicalParticipantBySportName(SPORT_ID, "Novak Djokovic");
expect(result).toEqual(SAMPLE_PARTICIPANT);
});
it("creates new participant if not found", async () => {
let callCount = 0;
vi.mocked(database).mockImplementation(() => {
callCount++;
return (callCount === 1 ? makeSelectDb([]) : makeInsertDb(SAMPLE_PARTICIPANT)) as never;
});
const result = await upsertCanonicalParticipantBySportName(SPORT_ID, "Novak Djokovic");
expect(result).toEqual(SAMPLE_PARTICIPANT);
});
it("passes extra fields when creating", async () => {
let callCount = 0;
vi.mocked(database).mockImplementation(() => {
callCount++;
return (callCount === 1 ? makeSelectDb([]) : makeInsertDb(SAMPLE_PARTICIPANT)) as never;
});
const result = await upsertCanonicalParticipantBySportName(
SPORT_ID,
"Novak Djokovic",
{ externalKey: "djokovic-n" }
);
expect(result).toEqual(SAMPLE_PARTICIPANT);
});
});

View file

@ -42,7 +42,7 @@ function makeDb(existingResult?: { id: string; isPartialScore: boolean }) {
insert: vi.fn().mockReturnValue({ values: insertValues }),
update: vi.fn().mockReturnValue({ set: updateSet }),
query: {
participantResults: { findFirst },
seasonParticipantResults: { findFirst },
seasonSports: {
findMany: vi.fn().mockResolvedValue([]), // empty → standings loop exits immediately
},
@ -430,10 +430,10 @@ describe("processPlayoffEvent - NBA Play-In Round 1 loserAdvances fix", () => {
playoffMatches: {
findMany: vi.fn().mockResolvedValue(matches),
},
participants: {
seasonParticipants: {
findMany: vi.fn().mockResolvedValue([]),
},
participantResults: {
seasonParticipantResults: {
findFirst: vi.fn().mockResolvedValue(undefined),
},
seasons: {

View file

@ -6,9 +6,9 @@ vi.mock("~/database/context", () => ({
vi.mock("~/database/schema", () => ({
sportsSeasons: { id: "ss.id", sportId: "ss.sport_id" },
participants: { sportsSeasonId: "p.sports_season_id" },
seasonParticipants: { sportsSeasonId: "p.sports_season_id" },
scoringEvents: { sportsSeasonId: "se.sports_season_id" },
participantExpectedValues: { sportsSeasonId: "pev.sports_season_id" },
seasonParticipantExpectedValues: { sportsSeasonId: "pev.sports_season_id" },
}));
vi.mock("drizzle-orm", () => ({
@ -91,16 +91,16 @@ function makeMockDb({
const db: any = {
query: {
sportsSeasons: { findFirst: vi.fn().mockResolvedValue(ss) },
participants: { findMany: vi.fn().mockResolvedValue(participants) },
seasonParticipants: { findMany: vi.fn().mockResolvedValue(participants) },
scoringEvents: { findMany: vi.fn().mockResolvedValue(scoringEvents) },
participantExpectedValues: { findMany: vi.fn().mockResolvedValue(sourceEvRows) },
seasonParticipantExpectedValues: { findMany: vi.fn().mockResolvedValue(sourceEvRows) },
},
insert: vi.fn().mockImplementation((table: object) => {
let key: string;
let returnRows: object[];
if (table === schema.sportsSeasons) {
key = "sportsSeasons"; returnRows = [insertedSeason];
} else if (table === schema.participants) {
} else if (table === schema.seasonParticipants) {
key = "participants"; returnRows = newParticipantRows;
} else if (table === schema.scoringEvents) {
key = "scoringEvents"; returnRows = [];

View file

@ -64,7 +64,7 @@ function makeDb(opts: MakeDbOpts = {}) {
teamScoreEvents: {
findMany: vi.fn().mockResolvedValue(scoreEventRows),
},
participants: {
seasonParticipants: {
findMany: vi.fn().mockResolvedValue(participantRows),
},
},
@ -268,7 +268,7 @@ describe("getRecentTeamScoreEvents", () => {
it("does not query participants when rows array is empty", async () => {
const { db } = makeDb({ scoreEventRows: [] });
await getRecentTeamScoreEvents("season-1", 10, db);
expect(db.query.participants.findMany).not.toHaveBeenCalled();
expect(db.query.seasonParticipants.findMany).not.toHaveBeenCalled();
});
it("returns mapped entries with resolved participant names", async () => {

View file

@ -0,0 +1,114 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
import {
upsertTournamentResult,
getTournamentResults,
getTournamentResultByParticipant,
} from "../tournament-result";
import { database } from "~/database/context";
const TOURNAMENT_ID = "tournament-1";
const PARTICIPANT_ID = "participant-1";
const SAMPLE_RESULT = {
id: "result-1",
tournamentId: TOURNAMENT_ID,
participantId: PARTICIPANT_ID,
placement: 1,
rawScore: "7500.00",
createdAt: new Date("2026-01-28T00:00:00Z"),
updatedAt: new Date("2026-01-28T00:00:00Z"),
};
function makeUpsertDb(returnValue: object) {
return {
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({
onConflictDoUpdate: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([returnValue]),
}),
}),
}),
};
}
function makeSelectDb(rows: object[]) {
return {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue(rows),
orderBy: vi.fn().mockResolvedValue(rows),
}),
}),
}),
};
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("upsertTournamentResult", () => {
it("inserts or updates a result", async () => {
vi.mocked(database).mockReturnValue(makeUpsertDb(SAMPLE_RESULT) as never);
const result = await upsertTournamentResult({
tournamentId: TOURNAMENT_ID,
participantId: PARTICIPANT_ID,
placement: 1,
rawScore: "7500.00",
});
expect(result).toEqual(SAMPLE_RESULT);
});
it("uses onConflictDoUpdate on tournamentId and participantId", async () => {
const mockDb = makeUpsertDb(SAMPLE_RESULT);
vi.mocked(database).mockReturnValue(mockDb as never);
await upsertTournamentResult({
tournamentId: TOURNAMENT_ID,
participantId: PARTICIPANT_ID,
placement: 1,
});
const onConflictCall = (mockDb.insert as ReturnType<typeof vi.fn>)
.mock.results[0].value.values.mock.results[0].value.onConflictDoUpdate;
expect(onConflictCall).toHaveBeenCalled();
});
});
describe("getTournamentResults", () => {
it("returns results ordered by placement", async () => {
const results = [SAMPLE_RESULT];
vi.mocked(database).mockReturnValue(makeSelectDb(results) as never);
const result = await getTournamentResults(TOURNAMENT_ID);
expect(result).toEqual(results);
});
});
describe("getTournamentResultByParticipant", () => {
it("returns the result when found", async () => {
vi.mocked(database).mockReturnValue(makeSelectDb([SAMPLE_RESULT]) as never);
const result = await getTournamentResultByParticipant(TOURNAMENT_ID, PARTICIPANT_ID);
expect(result).toEqual(SAMPLE_RESULT);
});
it("returns null when not found", async () => {
vi.mocked(database).mockReturnValue(makeSelectDb([]) as never);
const result = await getTournamentResultByParticipant(TOURNAMENT_ID, "nonexistent");
expect(result).toBeNull();
});
});

View file

@ -0,0 +1,174 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
import {
createTournament,
getTournamentById,
findTournamentsBySport,
findTournamentBySportNameYear,
upsertTournament,
updateTournamentStatus,
} from "../tournament";
import { database } from "~/database/context";
const SPORT_ID = "sport-1";
const TOURNAMENT_ID = "tournament-1";
const SAMPLE_TOURNAMENT = {
id: TOURNAMENT_ID,
sportId: SPORT_ID,
name: "Australian Open",
year: 2026,
startsAt: new Date("2026-01-15T00:00:00Z"),
endsAt: new Date("2026-01-28T00:00:00Z"),
surface: "hard",
location: "Melbourne",
status: "scheduled" as const,
externalKey: "ao-2026",
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
};
function makeInsertDb(returnValue: object) {
return {
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([returnValue]),
}),
}),
};
}
function makeSelectDb(rows: object[]) {
return {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue(rows),
orderBy: vi.fn().mockResolvedValue(rows),
}),
}),
}),
};
}
function makeUpdateDb(returnValue: object) {
return {
update: vi.fn().mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([returnValue]),
}),
}),
}),
};
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("createTournament", () => {
it("inserts a tournament and returns it", async () => {
vi.mocked(database).mockReturnValue(makeInsertDb(SAMPLE_TOURNAMENT) as never);
const result = await createTournament({
sportId: SPORT_ID,
name: "Australian Open",
year: 2026,
});
expect(result).toEqual(SAMPLE_TOURNAMENT);
});
});
describe("getTournamentById", () => {
it("returns the tournament when found", async () => {
vi.mocked(database).mockReturnValue(makeSelectDb([SAMPLE_TOURNAMENT]) as never);
const result = await getTournamentById(TOURNAMENT_ID);
expect(result).toEqual(SAMPLE_TOURNAMENT);
});
it("returns null when not found", async () => {
vi.mocked(database).mockReturnValue(makeSelectDb([]) as never);
const result = await getTournamentById("nonexistent");
expect(result).toBeNull();
});
});
describe("findTournamentsBySport", () => {
it("returns tournaments ordered by year and date", async () => {
const tournaments = [SAMPLE_TOURNAMENT];
vi.mocked(database).mockReturnValue(makeSelectDb(tournaments) as never);
const result = await findTournamentsBySport(SPORT_ID);
expect(result).toEqual(tournaments);
});
});
describe("findTournamentBySportNameYear", () => {
it("returns the tournament when found", async () => {
vi.mocked(database).mockReturnValue(makeSelectDb([SAMPLE_TOURNAMENT]) as never);
const result = await findTournamentBySportNameYear(SPORT_ID, "Australian Open", 2026);
expect(result).toEqual(SAMPLE_TOURNAMENT);
});
it("returns null when not found", async () => {
vi.mocked(database).mockReturnValue(makeSelectDb([]) as never);
const result = await findTournamentBySportNameYear(SPORT_ID, "Wimbledon", 2026);
expect(result).toBeNull();
});
});
describe("upsertTournament", () => {
it("returns existing tournament if found", async () => {
vi.mocked(database).mockReturnValue(makeSelectDb([SAMPLE_TOURNAMENT]) as never);
const result = await upsertTournament({
sportId: SPORT_ID,
name: "Australian Open",
year: 2026,
});
expect(result).toEqual(SAMPLE_TOURNAMENT);
});
it("creates new tournament if not found", async () => {
let callCount = 0;
vi.mocked(database).mockImplementation(() => {
callCount++;
return (callCount === 1 ? makeSelectDb([]) : makeInsertDb(SAMPLE_TOURNAMENT)) as never;
});
const result = await upsertTournament({
sportId: SPORT_ID,
name: "Australian Open",
year: 2026,
});
expect(result).toEqual(SAMPLE_TOURNAMENT);
});
});
describe("updateTournamentStatus", () => {
it("updates status and returns the tournament", async () => {
const updated = { ...SAMPLE_TOURNAMENT, status: "in_progress" as const };
vi.mocked(database).mockReturnValue(makeUpdateDb(updated) as never);
const result = await updateTournamentStatus(TOURNAMENT_ID, "in_progress");
expect(result.status).toBe("in_progress");
});
});

View file

@ -0,0 +1,43 @@
import { eq } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
export type CanonicalSurfaceElo = typeof schema.participantSurfaceElos.$inferSelect;
export type NewCanonicalSurfaceElo = typeof schema.participantSurfaceElos.$inferInsert;
export async function upsertCanonicalSurfaceElo(
data: NewCanonicalSurfaceElo
): Promise<CanonicalSurfaceElo> {
if (!data.participantId) {
throw new Error("participantId is required");
}
const db = database();
const [result] = await db
.insert(schema.participantSurfaceElos)
.values(data)
.onConflictDoUpdate({
target: [schema.participantSurfaceElos.participantId],
set: {
worldRanking: data.worldRanking,
eloHard: data.eloHard,
eloClay: data.eloClay,
eloGrass: data.eloGrass,
updatedAt: new Date(),
},
})
.returning();
return result;
}
export async function getCanonicalSurfaceElo(
participantId: string
): Promise<CanonicalSurfaceElo | null> {
const db = database();
const results = await db
.select()
.from(schema.participantSurfaceElos)
.where(eq(schema.participantSurfaceElos.participantId, participantId))
.limit(1);
return results[0] ?? null;
}

View file

@ -13,7 +13,7 @@
*/
import { database } from "~/database/context";
import { cs2MajorStageResults, participants } from "~/database/schema";
import { cs2MajorStageResults, seasonParticipants } from "~/database/schema";
import { eq, and, sql } from "drizzle-orm";
export interface Cs2StageResult {
@ -56,12 +56,12 @@ export async function getCs2StageResultsForEvent(
finalPlacement: cs2MajorStageResults.finalPlacement,
createdAt: cs2MajorStageResults.createdAt,
updatedAt: cs2MajorStageResults.updatedAt,
participantName: participants.name,
participantName: seasonParticipants.name,
})
.from(cs2MajorStageResults)
.innerJoin(participants, eq(cs2MajorStageResults.participantId, participants.id))
.innerJoin(seasonParticipants, eq(cs2MajorStageResults.participantId, seasonParticipants.id))
.where(eq(cs2MajorStageResults.scoringEventId, scoringEventId))
.orderBy(cs2MajorStageResults.stageEntry, participants.name);
.orderBy(cs2MajorStageResults.stageEntry, seasonParticipants.name);
return rows;
}
@ -135,7 +135,7 @@ export async function clearCs2StageAssignments(
/**
* Mark teams as eliminated from a specific stage.
* Records stageEliminated and stageEliminatedWins for the specified participants.
* Records stageEliminated and stageEliminatedWins for the specified seasonParticipants.
* Teams NOT in eliminatedEntries that are in this stage are implicitly considered
* to have advanced (stageEliminated remains null).
*/
@ -166,7 +166,7 @@ export async function markCs2StageEliminations(
}
/**
* Set final placements for all participants in a CS2 Major event.
* Set final placements for all seasonParticipants in a CS2 Major event.
* Called after the Champions Stage is complete.
* placements: Map from participantId to final placement (132)
*/

View file

@ -78,14 +78,14 @@ export async function getDraftedParticipantsBySportsSeason(
const results = await db
.select({
sportsSeasonId: schema.participants.sportsSeasonId,
participantId: schema.participants.id,
participantName: schema.participants.name,
sportsSeasonId: schema.seasonParticipants.sportsSeasonId,
participantId: schema.seasonParticipants.id,
participantName: schema.seasonParticipants.name,
})
.from(schema.draftPicks)
.innerJoin(
schema.participants,
eq(schema.draftPicks.participantId, schema.participants.id)
schema.seasonParticipants,
eq(schema.draftPicks.participantId, schema.seasonParticipants.id)
)
.where(
and(
@ -182,10 +182,10 @@ export async function getDraftedParticipantsWithPoints(
// Batch-fetch QP totals for qualifying_points participants
const qpMap = new Map<string, number>(); // participantId → totalQP
if (qpParticipantIds.size > 0 && qpSeasonIds.size > 0) {
const totals = await db.query.participantQualifyingTotals.findMany({
const totals = await db.query.seasonParticipantQualifyingTotals.findMany({
where: and(
inArray(schema.participantQualifyingTotals.participantId, [...qpParticipantIds]),
inArray(schema.participantQualifyingTotals.sportsSeasonId, [...qpSeasonIds])
inArray(schema.seasonParticipantQualifyingTotals.participantId, [...qpParticipantIds]),
inArray(schema.seasonParticipantQualifyingTotals.sportsSeasonId, [...qpSeasonIds])
),
columns: { participantId: true, totalQualifyingPoints: true },
});
@ -245,19 +245,19 @@ export async function getDraftPicksWithSports(seasonId: string, providedDb?: Ret
id: schema.draftPicks.id,
teamId: schema.draftPicks.teamId,
pickNumber: schema.draftPicks.pickNumber,
participantId: schema.participants.id,
participantName: schema.participants.name,
participantId: schema.seasonParticipants.id,
participantName: schema.seasonParticipants.name,
sportId: schema.sports.id,
sportName: schema.sports.name,
})
.from(schema.draftPicks)
.innerJoin(
schema.participants,
eq(schema.draftPicks.participantId, schema.participants.id)
schema.seasonParticipants,
eq(schema.draftPicks.participantId, schema.seasonParticipants.id)
)
.innerJoin(
schema.sportsSeasons,
eq(schema.participants.sportsSeasonId, schema.sportsSeasons.id)
eq(schema.seasonParticipants.sportsSeasonId, schema.sportsSeasons.id)
)
.innerJoin(
schema.sports,
@ -289,19 +289,19 @@ export async function getTeamDraftPicksWithSports(teamId: string, seasonId: stri
id: schema.draftPicks.id,
teamId: schema.draftPicks.teamId,
pickNumber: schema.draftPicks.pickNumber,
participantId: schema.participants.id,
participantName: schema.participants.name,
participantId: schema.seasonParticipants.id,
participantName: schema.seasonParticipants.name,
sportId: schema.sports.id,
sportName: schema.sports.name,
})
.from(schema.draftPicks)
.innerJoin(
schema.participants,
eq(schema.draftPicks.participantId, schema.participants.id)
schema.seasonParticipants,
eq(schema.draftPicks.participantId, schema.seasonParticipants.id)
)
.innerJoin(
schema.sportsSeasons,
eq(schema.participants.sportsSeasonId, schema.sportsSeasons.id)
eq(schema.seasonParticipants.sportsSeasonId, schema.sportsSeasons.id)
)
.innerJoin(
schema.sports,
@ -338,13 +338,13 @@ export async function getDraftPicksForSeason(seasonId: string) {
pickInRound: schema.draftPicks.pickInRound,
timeUsed: schema.draftPicks.timeUsed,
team: schema.teams,
participant: schema.participants,
participant: schema.seasonParticipants,
sport: schema.sports,
})
.from(schema.draftPicks)
.innerJoin(schema.teams, eq(schema.draftPicks.teamId, schema.teams.id))
.innerJoin(schema.participants, eq(schema.draftPicks.participantId, schema.participants.id))
.innerJoin(schema.sportsSeasons, eq(schema.participants.sportsSeasonId, schema.sportsSeasons.id))
.innerJoin(schema.seasonParticipants, eq(schema.draftPicks.participantId, schema.seasonParticipants.id))
.innerJoin(schema.sportsSeasons, eq(schema.seasonParticipants.sportsSeasonId, schema.sportsSeasons.id))
.innerJoin(schema.sports, eq(schema.sportsSeasons.sportId, schema.sports.id))
.where(eq(schema.draftPicks.seasonId, seasonId))
.orderBy(asc(schema.draftPicks.pickNumber));

View file

@ -5,7 +5,7 @@ import { logger } from "~/lib/logger";
import type { InferSelectModel } from "drizzle-orm";
import { getTeamQueue, getAllQueuesForSeason } from "./draft-queue";
import { isParticipantDrafted, getDraftPicksWithSports, getTeamDraftPicksWithSports } from "./draft-pick";
import { getParticipantsForSeasonWithSports } from "./participant";
import { getParticipantsForSeasonWithSports } from "./season-participant";
import { getSeasonSportsSimple } from "./season-sport";
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
import { getSocketIO, scheduleDraftRoomClosure } from "../../server/socket";
@ -118,8 +118,8 @@ export async function autoPickForTeam(
// Get participant details for queue items to check eligibility
const queueParticipantIds = queue.map((item) => item.participantId);
const queueParticipants = await db.query.participants.findMany({
where: inArray(schema.participants.id, queueParticipantIds),
const queueParticipants = await db.query.seasonParticipants.findMany({
where: inArray(schema.seasonParticipants.id, queueParticipantIds),
with: {
sportsSeason: {
with: {
@ -264,17 +264,17 @@ export async function getTopAvailableParticipant(
for (const sportsSeasonId of sportsSeasonIds) {
let participantQuery = db
.select()
.from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId));
.from(schema.seasonParticipants)
.where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId));
if (draftedIds.length > 0) {
participantQuery = db
.select()
.from(schema.participants)
.from(schema.seasonParticipants)
.where(
and(
eq(schema.participants.sportsSeasonId, sportsSeasonId),
notInArray(schema.participants.id, draftedIds)
eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId),
notInArray(schema.seasonParticipants.id, draftedIds)
)
);
}
@ -299,21 +299,21 @@ export async function getTopAvailableParticipant(
// Single sport season
let query = db
.select()
.from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonIds[0]))
.orderBy(desc(schema.participants.vorpValue), schema.participants.name);
.from(schema.seasonParticipants)
.where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonIds[0]))
.orderBy(desc(schema.seasonParticipants.vorpValue), schema.seasonParticipants.name);
if (draftedIds.length > 0) {
query = db
.select()
.from(schema.participants)
.from(schema.seasonParticipants)
.where(
and(
eq(schema.participants.sportsSeasonId, sportsSeasonIds[0]),
notInArray(schema.participants.id, draftedIds)
eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonIds[0]),
notInArray(schema.seasonParticipants.id, draftedIds)
)
)
.orderBy(desc(schema.participants.vorpValue), schema.participants.name);
.orderBy(desc(schema.seasonParticipants.vorpValue), schema.seasonParticipants.name);
}
const [topParticipant] = await query;
@ -471,7 +471,7 @@ export async function executeAutoPick(params: {
success: boolean;
error?: string;
pick?: InferSelectModel<typeof schema.draftPicks>;
participant?: InferSelectModel<typeof schema.participants> & {
participant?: InferSelectModel<typeof schema.seasonParticipants> & {
sportsSeason: InferSelectModel<typeof schema.sportsSeasons> & {
sport: InferSelectModel<typeof schema.sports>;
};
@ -581,8 +581,8 @@ export async function executeAutoPick(params: {
}
// Get participant details
const participantToPick = await db.query.participants.findFirst({
where: eq(schema.participants.id, participantId),
const participantToPick = await db.query.seasonParticipants.findFirst({
where: eq(schema.seasonParticipants.id, participantId),
with: {
sportsSeason: {
with: {

View file

@ -31,7 +31,7 @@ export async function createEventResult(
.insert(schema.eventResults)
.values({
scoringEventId: data.scoringEventId,
participantId: data.participantId,
seasonParticipantId: data.participantId,
placement: data.placement,
qualifyingPointsAwarded: data.qualifyingPointsAwarded?.toString(),
eliminated: data.eliminated,
@ -57,7 +57,7 @@ export async function createEventResultsBulk(
.values(
results.map((r) => ({
scoringEventId: r.scoringEventId,
participantId: r.participantId,
seasonParticipantId: r.participantId,
placement: r.placement,
qualifyingPointsAwarded: r.qualifyingPointsAwarded?.toString(),
eliminated: r.eliminated,
@ -81,7 +81,7 @@ export async function getEventResultById(
return await db.query.eventResults.findFirst({
where: eq(schema.eventResults.id, resultId),
with: {
participant: {
seasonParticipant: {
with: {
sportsSeason: {
with: {
@ -108,7 +108,7 @@ export async function getEventResults(
where: eq(schema.eventResults.scoringEventId, eventId),
orderBy: schema.eventResults.placement,
with: {
participant: {
seasonParticipant: {
with: {
sportsSeason: {
with: {
@ -131,7 +131,7 @@ export async function getParticipantEventResults(
const db = providedDb || database();
return await db.query.eventResults.findMany({
where: eq(schema.eventResults.participantId, participantId),
where: eq(schema.eventResults.seasonParticipantId, participantId),
with: {
scoringEvent: true,
},
@ -203,7 +203,7 @@ export async function hasParticipantResult(
const result = await db.query.eventResults.findFirst({
where: and(
eq(schema.eventResults.scoringEventId, eventId),
eq(schema.eventResults.participantId, participantId)
eq(schema.eventResults.seasonParticipantId, participantId)
),
});
@ -226,10 +226,10 @@ export async function getEventResultsForParticipants(
return await db.query.eventResults.findMany({
where: and(
eq(schema.eventResults.scoringEventId, eventId),
inArray(schema.eventResults.participantId, participantIds)
inArray(schema.eventResults.seasonParticipantId, participantIds)
),
with: {
participant: {
seasonParticipant: {
with: {
sportsSeason: {
with: {

View file

@ -7,7 +7,7 @@
*/
import { database } from "~/database/context";
import { participantGolfSkills, participants } from "~/database/schema";
import { participantGolfSkills, seasonParticipants } from "~/database/schema";
import { eq, sql } from "drizzle-orm";
export interface GolfSkillsRecord {
@ -40,7 +40,7 @@ export interface GolfSkillsInput {
/**
* Get all golf skill records for a sports season, joined with participant names.
* Returns one record per participant (participants with no record are excluded).
* Returns one record per participant (seasonParticipants with no record are excluded).
*/
export async function getGolfSkillsForSeason(
sportsSeasonId: string
@ -58,12 +58,12 @@ export async function getGolfSkillsForSeason(
openChampionshipOdds: participantGolfSkills.openChampionshipOdds,
pgaChampionshipOdds: participantGolfSkills.pgaChampionshipOdds,
updatedAt: participantGolfSkills.updatedAt,
participantName: participants.name,
participantName: seasonParticipants.name,
})
.from(participantGolfSkills)
.innerJoin(participants, eq(participantGolfSkills.participantId, participants.id))
.innerJoin(seasonParticipants, eq(participantGolfSkills.participantId, seasonParticipants.id))
.where(eq(participantGolfSkills.sportsSeasonId, sportsSeasonId))
.orderBy(participants.name);
.orderBy(seasonParticipants.name);
return rows.map((r) => ({
...r,
@ -94,7 +94,7 @@ export async function getGolfSkillsMap(
}
/**
* Upsert golf skill ratings for a batch of participants.
* Upsert golf skill ratings for a batch of seasonParticipants.
* Uses INSERT ON CONFLICT DO UPDATE so all columns are overwritten atomically.
*/
export async function batchUpsertGolfSkills(

View file

@ -334,8 +334,8 @@ export async function getUpcomingGroupStageMatchesForParticipants(
const allParticipantIds = [
...new Set(rows.flatMap((r) => [r.participant1Id, r.participant2Id])),
];
const participantRows = await db.query.participants.findMany({
where: inArray(schema.participants.id, allParticipantIds),
const participantRows = await db.query.seasonParticipants.findMany({
where: inArray(schema.seasonParticipants.id, allParticipantIds),
});
const participantMap = new Map(participantRows.map((p) => [p.id, p]));

View file

@ -6,7 +6,7 @@ export * from "./team";
export * from "./commissioner";
export * from "./sport";
export * from "./sports-season";
export * from "./participant";
export * from "./season-participant";
export * from "./season-template";
export * from "./season-template-sport";
export * from "./season-sport";
@ -18,3 +18,7 @@ export * from "./draft-timer";
export * from "./draft-utils";
export * from "./watchlist";
export * from "./audit-log";
export * from "./tournament";
export * from "./participant";
export * from "./tournament-result";
export * from "./canonical-surface-elo";

View file

@ -1,12 +1,12 @@
/**
* Model for Participant Expected Values
*
* Manages probability distributions and calculated EVs for participants
* Manages probability distributions and calculated EVs for seasonParticipants
* in sports seasons.
*/
import { database } from "~/database/context";
import { participantExpectedValues, participants } from "~/database/schema";
import { seasonParticipantExpectedValues, seasonParticipants } from "~/database/schema";
import { eq, and, count, sql } from "drizzle-orm";
import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calculator";
import { calculateEV, normalizeProbabilities, calculateReplacementLevel, calculateVORP } from "~/services/ev-calculator";
@ -53,9 +53,9 @@ export interface UpdateProbabilityInput {
* Recalculate and persist VORP for every participant in a sports season.
*
* VORP = participant EV replacement level EV
* Replacement level = average EV of participants ranked 12th14th in this season.
* Replacement level = average EV of seasonParticipants ranked 12th14th in this season.
*
* Call this after any operation that changes EVs for participants in the season.
* Call this after any operation that changes EVs for seasonParticipants in the season.
*/
export async function syncVorpForSeason(sportsSeasonId: string): Promise<void> {
const db = database();
@ -72,12 +72,12 @@ export async function syncVorpForSeason(sportsSeasonId: string): Promise<void> {
const now = new Date();
// Build a single CASE expression to update all participants in one query
// Build a single CASE expression to update all seasonParticipants in one query
// instead of N individual UPDATE statements.
const vorpCaseExpr = sql`CASE ${sql.join(
sorted.map((ev) => {
const vorp = calculateVORP(parseFloat(ev.expectedValue), replacementLevel);
return sql`WHEN ${participants.id} = ${ev.participantId} THEN ${vorp.toFixed(4)}::numeric`;
return sql`WHEN ${seasonParticipants.id} = ${ev.participantId} THEN ${vorp.toFixed(4)}::numeric`;
}),
sql` `
)} END`;
@ -85,9 +85,9 @@ export async function syncVorpForSeason(sportsSeasonId: string): Promise<void> {
const ids = sorted.map((ev) => ev.participantId);
await db
.update(participants)
.update(seasonParticipants)
.set({ vorpValue: vorpCaseExpr, updatedAt: now })
.where(sql`${participants.id} = ANY(${sql`ARRAY[${sql.join(ids.map((id) => sql`${id}`), sql`, `)}]::uuid[]`})`);
.where(sql`${seasonParticipants.id} = ANY(${sql`ARRAY[${sql.join(ids.map((id) => sql`${id}`), sql`, `)}]::uuid[]`})`);
}
/**
@ -118,11 +118,11 @@ export async function upsertParticipantEV(
// Check if record exists
const existing = await db
.select()
.from(participantExpectedValues)
.from(seasonParticipantExpectedValues)
.where(
and(
eq(participantExpectedValues.participantId, participantId),
eq(participantExpectedValues.sportsSeasonId, sportsSeasonId)
eq(seasonParticipantExpectedValues.participantId, participantId),
eq(seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)
)
)
.limit(1);
@ -134,7 +134,7 @@ export async function upsertParticipantEV(
if (existing.length > 0) {
// Update existing
const updated = await db
.update(participantExpectedValues)
.update(seasonParticipantExpectedValues)
.set({
probFirst: probabilities.probFirst.toString(),
probSecond: probabilities.probSecond.toString(),
@ -150,14 +150,14 @@ export async function upsertParticipantEV(
calculatedAt: now,
updatedAt: now,
})
.where(eq(participantExpectedValues.id, existing[0].id))
.where(eq(seasonParticipantExpectedValues.id, existing[0].id))
.returning();
result = updated[0];
} else {
// Create new
const created = await db
.insert(participantExpectedValues)
.insert(seasonParticipantExpectedValues)
.values({
participantId,
sportsSeasonId,
@ -180,13 +180,13 @@ export async function upsertParticipantEV(
result = created[0];
}
// Sync calculated EV to participants table for draft room ranking
// Sync calculated EV to seasonParticipants table for draft room ranking
await db
.update(participants)
.update(seasonParticipants)
.set({ expectedValue: expectedValue.toString(), updatedAt: now })
.where(eq(participants.id, participantId));
.where(eq(seasonParticipants.id, participantId));
// Recalculate VORP for all participants in this season (replacement level may have shifted)
// Recalculate VORP for all seasonParticipants in this season (replacement level may have shifted)
await syncVorpForSeason(sportsSeasonId);
return result;
@ -209,7 +209,7 @@ export async function upsertParticipantEVWithNormalization(
export async function countAllParticipantEVs(): Promise<number> {
const db = database();
const result = await db.select({ value: count() }).from(participantExpectedValues);
const result = await db.select({ value: count() }).from(seasonParticipantExpectedValues);
return result[0].value;
}
@ -223,11 +223,11 @@ export async function getParticipantEV(
const db = database();
const result = await db
.select()
.from(participantExpectedValues)
.from(seasonParticipantExpectedValues)
.where(
and(
eq(participantExpectedValues.participantId, participantId),
eq(participantExpectedValues.sportsSeasonId, sportsSeasonId)
eq(seasonParticipantExpectedValues.participantId, participantId),
eq(seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)
)
)
.limit(1);
@ -244,8 +244,8 @@ export async function getAllParticipantEVsForSeason(
const db = database();
return db
.select()
.from(participantExpectedValues)
.where(eq(participantExpectedValues.sportsSeasonId, sportsSeasonId));
.from(seasonParticipantExpectedValues)
.where(eq(seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
}
/**
@ -257,21 +257,21 @@ export async function deleteParticipantEV(
): Promise<void> {
const db = database();
await db
.delete(participantExpectedValues)
.delete(seasonParticipantExpectedValues)
.where(
and(
eq(participantExpectedValues.participantId, participantId),
eq(participantExpectedValues.sportsSeasonId, sportsSeasonId)
eq(seasonParticipantExpectedValues.participantId, participantId),
eq(seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)
)
);
// Reset this participant's EV and VORP to 0 (no longer ranked)
await db
.update(participants)
.update(seasonParticipants)
.set({ expectedValue: "0", vorpValue: "0", updatedAt: new Date() })
.where(eq(participants.id, participantId));
.where(eq(seasonParticipants.id, participantId));
// Recalculate VORP for remaining participants — replacement level may have shifted
// Recalculate VORP for remaining seasonParticipants — replacement level may have shifted
await syncVorpForSeason(sportsSeasonId);
}
@ -301,12 +301,12 @@ export async function batchUpsertParticipantEVs(
const expectedValue = calculateEV(probabilities, scoringRules);
const existing = await tx
.select({ id: participantExpectedValues.id })
.from(participantExpectedValues)
.select({ id: seasonParticipantExpectedValues.id })
.from(seasonParticipantExpectedValues)
.where(
and(
eq(participantExpectedValues.participantId, participantId),
eq(participantExpectedValues.sportsSeasonId, sportsSeasonId)
eq(seasonParticipantExpectedValues.participantId, participantId),
eq(seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)
)
)
.limit(1);
@ -330,24 +330,24 @@ export async function batchUpsertParticipantEVs(
if (existing.length > 0) {
const [updated] = await tx
.update(participantExpectedValues)
.update(seasonParticipantExpectedValues)
// Only overwrite sourceOdds if explicitly provided; preserve existing value otherwise
.set(sourceOdds !== undefined ? { ...baseValues, sourceOdds } : baseValues)
.where(eq(participantExpectedValues.id, existing[0].id))
.where(eq(seasonParticipantExpectedValues.id, existing[0].id))
.returning();
result = updated;
} else {
const [created] = await tx
.insert(participantExpectedValues)
.insert(seasonParticipantExpectedValues)
.values({ participantId, sportsSeasonId, ...baseValues, sourceOdds: sourceOdds ?? null })
.returning();
result = created;
}
await tx
.update(participants)
.update(seasonParticipants)
.set({ expectedValue: expectedValue.toString(), updatedAt: now })
.where(eq(participants.id, participantId));
.where(eq(seasonParticipants.id, participantId));
results.push(result);
}
@ -361,7 +361,7 @@ export async function batchUpsertParticipantEVs(
}
/**
* Save American odds for a batch of participants without touching probabilities or EV.
* Save American odds for a batch of seasonParticipants without touching probabilities or EV.
* Used by the futures-odds admin page to persist odds before running the full simulation.
*/
export async function batchSaveSourceOdds(
@ -374,24 +374,24 @@ export async function batchSaveSourceOdds(
for (const { participantId, sportsSeasonId, sourceOdds } of inputs) {
const existing = await tx
.select({ id: participantExpectedValues.id })
.from(participantExpectedValues)
.select({ id: seasonParticipantExpectedValues.id })
.from(seasonParticipantExpectedValues)
.where(
and(
eq(participantExpectedValues.participantId, participantId),
eq(participantExpectedValues.sportsSeasonId, sportsSeasonId)
eq(seasonParticipantExpectedValues.participantId, participantId),
eq(seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)
)
)
.limit(1);
if (existing.length > 0) {
await tx
.update(participantExpectedValues)
.update(seasonParticipantExpectedValues)
.set({ sourceOdds, updatedAt: now })
.where(eq(participantExpectedValues.id, existing[0].id));
.where(eq(seasonParticipantExpectedValues.id, existing[0].id));
} else {
// Insert a stub record — probabilities/EV will be filled in by the simulator
await tx.insert(participantExpectedValues).values({
await tx.insert(seasonParticipantExpectedValues).values({
participantId,
sportsSeasonId,
probFirst: "0",
@ -414,7 +414,7 @@ export async function batchSaveSourceOdds(
}
/**
* Persist raw Elo ratings (and optional world rankings) for a batch of participants.
* Persist raw Elo ratings (and optional world rankings) for a batch of seasonParticipants.
* Used by Elo-based simulators like snooker_bracket and darts_bracket.
* Creates stub records if none exist; does not touch probabilities or EV.
* Uses INSERT ... ON CONFLICT DO UPDATE to avoid N+1 queries.
@ -427,7 +427,7 @@ export async function batchSaveSourceElos(
const now = new Date();
await db
.insert(participantExpectedValues)
.insert(seasonParticipantExpectedValues)
.values(
inputs.map(({ participantId, sportsSeasonId, sourceElo, worldRanking }) => ({
participantId,
@ -449,7 +449,7 @@ export async function batchSaveSourceElos(
}))
)
.onConflictDoUpdate({
target: [participantExpectedValues.participantId, participantExpectedValues.sportsSeasonId],
target: [seasonParticipantExpectedValues.participantId, seasonParticipantExpectedValues.sportsSeasonId],
set: {
sourceElo: sql`excluded.source_elo`,
worldRanking: sql`excluded.world_ranking`,
@ -499,26 +499,26 @@ export async function recalculateEV(
const db = database();
const now = new Date();
const updated = await db
.update(participantExpectedValues)
.update(seasonParticipantExpectedValues)
.set({
expectedValue: newEV.toString(),
calculatedAt: now,
updatedAt: now,
})
.where(eq(participantExpectedValues.id, existing.id))
.where(eq(seasonParticipantExpectedValues.id, existing.id))
.returning();
// Sync recalculated EV to participants table
// Sync recalculated EV to seasonParticipants table
await db
.update(participants)
.update(seasonParticipants)
.set({ expectedValue: newEV.toString(), updatedAt: now })
.where(eq(participants.id, participantId));
.where(eq(seasonParticipants.id, participantId));
return updated[0];
}
/**
* Recalculate EVs for all participants in a sports season
* Recalculate EVs for all seasonParticipants in a sports season
* Used when scoring rules change
*/
export async function recalculateAllEVsForSeason(

View file

@ -2,18 +2,18 @@ import { eq, and } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
export type ParticipantResult = typeof schema.participantResults.$inferSelect;
export type ParticipantResult = typeof schema.seasonParticipantResults.$inferSelect;
export type ParticipantResultWithParticipant = ParticipantResult & {
participant: { id: string; name: string } | null;
};
export type NewParticipantResult = typeof schema.participantResults.$inferInsert;
export type NewParticipantResult = typeof schema.seasonParticipantResults.$inferInsert;
export async function createParticipantResult(
data: NewParticipantResult
): Promise<ParticipantResult> {
const db = database();
const [result] = await db
.insert(schema.participantResults)
.insert(schema.seasonParticipantResults)
.values(data)
.returning();
return result;
@ -24,7 +24,7 @@ export async function createManyParticipantResults(
): Promise<ParticipantResult[]> {
const db = database();
return await db
.insert(schema.participantResults)
.insert(schema.seasonParticipantResults)
.values(data)
.returning();
}
@ -33,8 +33,8 @@ export async function findParticipantResultById(
id: string
): Promise<ParticipantResult | undefined> {
const db = database();
return await db.query.participantResults.findFirst({
where: eq(schema.participantResults.id, id),
return await db.query.seasonParticipantResults.findFirst({
where: eq(schema.seasonParticipantResults.id, id),
with: {
participant: true,
sportsSeason: {
@ -50,8 +50,8 @@ export async function findParticipantResultByParticipantId(
participantId: string
): Promise<ParticipantResult | undefined> {
const db = database();
return await db.query.participantResults.findFirst({
where: eq(schema.participantResults.participantId, participantId),
return await db.query.seasonParticipantResults.findFirst({
where: eq(schema.seasonParticipantResults.participantId, participantId),
with: {
participant: true,
sportsSeason: {
@ -67,8 +67,8 @@ export async function findParticipantResultsBySportsSeasonId(
sportsSeasonId: string
): Promise<ParticipantResultWithParticipant[]> {
const db = database();
return await db.query.participantResults.findMany({
where: eq(schema.participantResults.sportsSeasonId, sportsSeasonId),
return await db.query.seasonParticipantResults.findMany({
where: eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId),
orderBy: (results, { asc }) => [asc(results.finalPosition)],
with: {
participant: true,
@ -82,16 +82,16 @@ export async function updateParticipantResult(
): Promise<ParticipantResult> {
const db = database();
const [result] = await db
.update(schema.participantResults)
.update(schema.seasonParticipantResults)
.set({ ...data, updatedAt: new Date() })
.where(eq(schema.participantResults.id, id))
.where(eq(schema.seasonParticipantResults.id, id))
.returning();
return result;
}
export async function deleteParticipantResult(id: string): Promise<void> {
const db = database();
await db.delete(schema.participantResults).where(eq(schema.participantResults.id, id));
await db.delete(schema.seasonParticipantResults).where(eq(schema.seasonParticipantResults.id, id));
}
export async function deleteParticipantResultsBySportsSeasonId(
@ -99,8 +99,8 @@ export async function deleteParticipantResultsBySportsSeasonId(
): Promise<void> {
const db = database();
await db
.delete(schema.participantResults)
.where(eq(schema.participantResults.sportsSeasonId, sportsSeasonId));
.delete(schema.seasonParticipantResults)
.where(eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId));
}
/**
@ -117,10 +117,10 @@ export async function setParticipantResult(
const db = database();
// Check if result already exists
const existing = await db.query.participantResults.findFirst({
const existing = await db.query.seasonParticipantResults.findFirst({
where: and(
eq(schema.participantResults.participantId, participantId),
eq(schema.participantResults.sportsSeasonId, sportsSeasonId)
eq(schema.seasonParticipantResults.participantId, participantId),
eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId)
),
});

View file

@ -1,11 +1,13 @@
import { eq, inArray, count, and, sql, asc, desc } from "drizzle-orm";
import { eq, and, asc } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
export type Participant = typeof schema.participants.$inferSelect;
export type NewParticipant = typeof schema.participants.$inferInsert;
export type CanonicalParticipant = typeof schema.participants.$inferSelect;
export type NewCanonicalParticipant = typeof schema.participants.$inferInsert;
export async function createParticipant(data: NewParticipant): Promise<Participant> {
export async function createCanonicalParticipant(
data: NewCanonicalParticipant
): Promise<CanonicalParticipant> {
const db = database();
const [participant] = await db
.insert(schema.participants)
@ -14,187 +16,61 @@ export async function createParticipant(data: NewParticipant): Promise<Participa
return participant;
}
export async function createManyParticipants(data: NewParticipant[]): Promise<Participant[]> {
export async function getCanonicalParticipantById(
id: string
): Promise<CanonicalParticipant | null> {
const db = database();
const results = await db
.select()
.from(schema.participants)
.where(eq(schema.participants.id, id))
.limit(1);
return results[0] ?? null;
}
export async function findCanonicalParticipantsBySport(
sportId: string
): Promise<CanonicalParticipant[]> {
const db = database();
return await db
.insert(schema.participants)
.values(data)
.returning();
.select()
.from(schema.participants)
.where(eq(schema.participants.sportId, sportId))
.orderBy(asc(schema.participants.name));
}
export async function findParticipantById(id: string): Promise<Participant | undefined> {
const db = database();
return await db.query.participants.findFirst({
where: eq(schema.participants.id, id),
with: {
sportsSeason: {
with: {
sport: true,
},
},
},
});
}
export async function findParticipantByName(
sportsSeasonId: string,
export async function findCanonicalParticipantBySportName(
sportId: string,
name: string
): Promise<Participant | undefined> {
): Promise<CanonicalParticipant | null> {
const db = database();
const results = await db
.select()
.from(schema.participants)
.where(
and(
eq(schema.participants.sportsSeasonId, sportsSeasonId),
sql`lower(${schema.participants.name}) = lower(${name})`
eq(schema.participants.sportId, sportId),
eq(schema.participants.name, name)
)
)
.limit(1);
return results[0];
return results[0] ?? null;
}
export async function findParticipantsBySportsSeasonId(sportsSeasonId: string): Promise<Participant[]> {
const db = database();
return await db.query.participants.findMany({
where: eq(schema.participants.sportsSeasonId, sportsSeasonId),
orderBy: (participants, { asc: orderAsc }) => [orderAsc(participants.name)],
});
}
export async function upsertCanonicalParticipantBySportName(
sportId: string,
name: string,
extra?: Partial<NewCanonicalParticipant>
): Promise<CanonicalParticipant> {
const existing = await findCanonicalParticipantBySportName(sportId, name);
export async function findParticipantsByExternalId(
externalId: string
): Promise<Participant[]> {
const db = database();
return await db.query.participants.findMany({
where: eq(schema.participants.externalId, externalId),
with: {
sportsSeason: {
with: {
sport: true,
},
},
},
});
}
export async function updateParticipant(
id: string,
data: Partial<NewParticipant>
): Promise<Participant> {
const db = database();
const [participant] = await db
.update(schema.participants)
.set({ ...data, updatedAt: new Date() })
.where(eq(schema.participants.id, id))
.returning();
return participant;
}
export async function countAllParticipants(): Promise<number> {
const db = database();
const result = await db.select({ value: count() }).from(schema.participants);
return result[0].value;
}
export async function deleteParticipant(id: string): Promise<void> {
const db = database();
await db.delete(schema.participants).where(eq(schema.participants.id, id));
}
export async function copyParticipantsFromSeason(
sourceSportsSeasonId: string,
targetSportsSeasonId: string
): Promise<Participant[]> {
// Get all participants from source season
const sourceParticipants = await findParticipantsBySportsSeasonId(sourceSportsSeasonId);
// Insert them into the target season
if (sourceParticipants.length === 0) {
return [];
if (existing) {
return existing;
}
return await createManyParticipants(
sourceParticipants.map((p) => ({
sportsSeasonId: targetSportsSeasonId,
name: p.name,
shortName: p.shortName,
externalId: p.externalId,
expectedValue: p.expectedValue,
}))
);
}
/**
* Get all participants for a season with sport information
* Used for draft eligibility calculations
*/
export async function getParticipantsForSeasonWithSports(seasonId: string, providedDb?: ReturnType<typeof database>) {
const db = providedDb || database();
// First get all sports seasons for this season
const seasonSports = await db.query.seasonSports.findMany({
where: eq(schema.seasonSports.seasonId, seasonId),
return await createCanonicalParticipant({
sportId,
name,
...extra,
});
if (seasonSports.length === 0) {
return [];
}
const sportsSeasonIds = seasonSports.map((ss) => ss.sportsSeasonId);
// Get all participants for these sports seasons with sport info
const participants = await db
.select({
id: schema.participants.id,
name: schema.participants.name,
sport: {
id: schema.sports.id,
name: schema.sports.name,
},
})
.from(schema.participants)
.innerJoin(
schema.sportsSeasons,
eq(schema.participants.sportsSeasonId, schema.sportsSeasons.id)
)
.innerJoin(
schema.sports,
eq(schema.sportsSeasons.sportId, schema.sports.id)
)
.where(
sportsSeasonIds.length === 1
? eq(schema.participants.sportsSeasonId, sportsSeasonIds[0])
: inArray(schema.participants.sportsSeasonId, sportsSeasonIds)
);
return participants;
}
export async function getDraftParticipants(seasonId: string) {
const db = database();
const seasonSports = await db.query.seasonSports.findMany({
where: eq(schema.seasonSports.seasonId, seasonId),
});
const sportsSeasonIds = seasonSports.map((ss) => ss.sportsSeasonId);
if (sportsSeasonIds.length === 0) return [];
return await db
.select({
id: schema.participants.id,
name: schema.participants.name,
vorpValue: schema.participants.vorpValue,
sport: schema.sports,
})
.from(schema.participants)
.innerJoin(schema.sportsSeasons, eq(schema.participants.sportsSeasonId, schema.sportsSeasons.id))
.innerJoin(schema.sports, eq(schema.sportsSeasons.sportId, schema.sports.id))
.where(
sportsSeasonIds.length === 1
? eq(schema.participants.sportsSeasonId, sportsSeasonIds[0])
: inArray(schema.participants.sportsSeasonId, sportsSeasonIds)
)
.orderBy(desc(schema.participants.vorpValue), asc(schema.participants.name));
}

View file

@ -144,16 +144,16 @@ export async function getOrCreateParticipantQPTotal(
) {
const db = providedDb || database();
let total = await db.query.participantQualifyingTotals.findFirst({
let total = await db.query.seasonParticipantQualifyingTotals.findFirst({
where: and(
eq(schema.participantQualifyingTotals.participantId, participantId),
eq(schema.participantQualifyingTotals.sportsSeasonId, sportsSeasonId)
eq(schema.seasonParticipantQualifyingTotals.participantId, participantId),
eq(schema.seasonParticipantQualifyingTotals.sportsSeasonId, sportsSeasonId)
),
});
if (!total) {
const [created] = await db
.insert(schema.participantQualifyingTotals)
.insert(schema.seasonParticipantQualifyingTotals)
.values({
participantId,
sportsSeasonId,
@ -184,12 +184,12 @@ export async function addQualifyingPoints(
// Update with new points (do NOT increment eventsScored here)
const [updated] = await db
.update(schema.participantQualifyingTotals)
.update(schema.seasonParticipantQualifyingTotals)
.set({
totalQualifyingPoints: (parseFloat(total.totalQualifyingPoints) + pointsToAdd).toString(),
updatedAt: new Date(),
})
.where(eq(schema.participantQualifyingTotals.id, total.id))
.where(eq(schema.seasonParticipantQualifyingTotals.id, total.id))
.returning();
return updated;
@ -208,7 +208,7 @@ export async function recalculateParticipantQP(
// Get all event results for this participant in this sports season
const eventResults = await db.query.eventResults.findMany({
where: eq(schema.eventResults.participantId, participantId),
where: eq(schema.eventResults.seasonParticipantId, participantId),
with: {
scoringEvent: true,
},
@ -238,13 +238,13 @@ export async function recalculateParticipantQP(
// Update with recalculated values
await db
.update(schema.participantQualifyingTotals)
.update(schema.seasonParticipantQualifyingTotals)
.set({
totalQualifyingPoints: totalQP.toString(),
eventsScored,
updatedAt: new Date(),
})
.where(eq(schema.participantQualifyingTotals.id, total.id));
.where(eq(schema.seasonParticipantQualifyingTotals.id, total.id));
return { totalQP, eventsScored };
}
@ -271,9 +271,9 @@ export async function getQPStandings(
) {
const db = providedDb || database();
return await db.query.participantQualifyingTotals.findMany({
where: eq(schema.participantQualifyingTotals.sportsSeasonId, sportsSeasonId),
orderBy: [desc(sql`CAST(${schema.participantQualifyingTotals.totalQualifyingPoints} AS DECIMAL)`)],
return await db.query.seasonParticipantQualifyingTotals.findMany({
where: eq(schema.seasonParticipantQualifyingTotals.sportsSeasonId, sportsSeasonId),
orderBy: [desc(sql`CAST(${schema.seasonParticipantQualifyingTotals.totalQualifyingPoints} AS DECIMAL)`)],
with: {
participant: true,
},
@ -325,9 +325,9 @@ export async function updateFinalRankings(
// Update all rankings
for (const update of updates) {
await db
.update(schema.participantQualifyingTotals)
.update(schema.seasonParticipantQualifyingTotals)
.set({ finalRanking: update.finalRanking, updatedAt: new Date() })
.where(eq(schema.participantQualifyingTotals.id, update.id));
.where(eq(schema.seasonParticipantQualifyingTotals.id, update.id));
}
return updates;
@ -343,6 +343,6 @@ export async function resetQualifyingPoints(
const db = providedDb || database();
await db
.delete(schema.participantQualifyingTotals)
.where(eq(schema.participantQualifyingTotals.sportsSeasonId, sportsSeasonId));
.delete(schema.seasonParticipantQualifyingTotals)
.where(eq(schema.seasonParticipantQualifyingTotals.sportsSeasonId, sportsSeasonId));
}

View file

@ -201,8 +201,8 @@ export async function processPlayoffEvent(
// PHASE 5.3: Handle teams that didn't make the playoffs
// Get all participants in the sports season
const allParticipants = await db.query.participants.findMany({
where: eq(schema.participants.sportsSeasonId, event.sportsSeasonId),
const allParticipants = await db.query.seasonParticipants.findMany({
where: eq(schema.seasonParticipants.sportsSeasonId, event.sportsSeasonId),
});
// Get all participant IDs that are in ANY playoff match (winners or losers)
@ -222,10 +222,10 @@ export async function processPlayoffEvent(
for (const participant of allParticipants) {
if (!participantsInBracket.has(participant.id)) {
// Check if they already have a result (don't overwrite)
const existingResult = await db.query.participantResults.findFirst({
const existingResult = await db.query.seasonParticipantResults.findFirst({
where: and(
eq(schema.participantResults.participantId, participant.id),
eq(schema.participantResults.sportsSeasonId, event.sportsSeasonId)
eq(schema.seasonParticipantResults.participantId, participant.id),
eq(schema.seasonParticipantResults.sportsSeasonId, event.sportsSeasonId)
),
});
@ -506,10 +506,10 @@ async function upsertParticipantResult(
db: ReturnType<typeof database>,
isPartialScore = false
): Promise<number | null> {
const existing = await db.query.participantResults.findFirst({
const existing = await db.query.seasonParticipantResults.findFirst({
where: and(
eq(schema.participantResults.participantId, participantId),
eq(schema.participantResults.sportsSeasonId, sportsSeasonId)
eq(schema.seasonParticipantResults.participantId, participantId),
eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId)
),
});
@ -520,16 +520,16 @@ async function upsertParticipantResult(
if (!existing.isPartialScore && isPartialScore) return null;
await db
.update(schema.participantResults)
.update(schema.seasonParticipantResults)
.set({
finalPosition,
isPartialScore,
updatedAt: new Date(),
})
.where(eq(schema.participantResults.id, existing.id));
.where(eq(schema.seasonParticipantResults.id, existing.id));
return existing.finalPosition ?? 0;
} else {
await db.insert(schema.participantResults).values({
await db.insert(schema.seasonParticipantResults).values({
participantId,
sportsSeasonId,
finalPosition,
@ -651,7 +651,7 @@ export async function processQualifyingEvent(
// Recalculate totals for all participants from scratch
// This is the source of truth - sums all their event_results
const participantIds = new Set(results.map(r => r.participantId));
const participantIds = new Set(results.map(r => r.seasonParticipantId));
for (const participantId of participantIds) {
await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
}
@ -752,10 +752,10 @@ export async function finalizeQualifyingPoints(
if (currentPlacement <= standings.length) {
for (let i = currentPlacement - 1; i < standings.length; i++) {
const standing = standings[i];
const hasResult = await db.query.participantResults.findFirst({
const hasResult = await db.query.seasonParticipantResults.findFirst({
where: and(
eq(schema.participantResults.participantId, standing.participantId),
eq(schema.participantResults.sportsSeasonId, sportsSeasonId)
eq(schema.seasonParticipantResults.participantId, standing.participantId),
eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId)
),
});
@ -1388,11 +1388,11 @@ export async function recalculateAffectedLeagues(
.filter((id): id is string => id !== null);
const finalizedLoserIds = new Set<string>();
if (loserParticipantIds.length > 0) {
const loserResults = await db.query.participantResults.findMany({
const loserResults = await db.query.seasonParticipantResults.findMany({
where: and(
inArray(schema.participantResults.participantId, loserParticipantIds),
eq(schema.participantResults.sportsSeasonId, sportsSeasonId),
eq(schema.participantResults.isPartialScore, false)
inArray(schema.seasonParticipantResults.participantId, loserParticipantIds),
eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId),
eq(schema.seasonParticipantResults.isPartialScore, false)
),
});
for (const r of loserResults) finalizedLoserIds.add(r.participantId);

View file

@ -104,7 +104,7 @@ export async function getScoringEventsForSportsSeason(
with: {
eventResults: {
with: {
participant: true,
seasonParticipant: true,
},
},
},
@ -224,7 +224,7 @@ export async function deleteScoringEvent(
const results = await db.query.eventResults.findMany({
where: eq(schema.eventResults.scoringEventId, eventId),
});
affectedParticipantIds = results.map((r) => r.participantId);
affectedParticipantIds = results.map((r) => r.seasonParticipantId);
wasQPProcessed = results.some(
(r) => r.qualifyingPointsAwarded && parseFloat(r.qualifyingPointsAwarded) > 0
);
@ -233,8 +233,8 @@ export async function deleteScoringEvent(
await db.transaction(async (tx) => {
if (event.eventType === "playoff_game") {
await tx
.delete(schema.participantResults)
.where(eq(schema.participantResults.sportsSeasonId, event.sportsSeasonId));
.delete(schema.seasonParticipantResults)
.where(eq(schema.seasonParticipantResults.sportsSeasonId, event.sportsSeasonId));
}
// Cascades: playoffMatches, eventResults, tournamentGroups/Members
await tx.delete(schema.scoringEvents).where(eq(schema.scoringEvents.id, eventId));

View file

@ -0,0 +1,200 @@
import { eq, inArray, count, and, sql, asc, desc } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
export type Participant = typeof schema.seasonParticipants.$inferSelect;
export type NewParticipant = typeof schema.seasonParticipants.$inferInsert;
export async function createParticipant(data: NewParticipant): Promise<Participant> {
const db = database();
const [participant] = await db
.insert(schema.seasonParticipants)
.values(data)
.returning();
return participant;
}
export async function createManyParticipants(data: NewParticipant[]): Promise<Participant[]> {
const db = database();
return await db
.insert(schema.seasonParticipants)
.values(data)
.returning();
}
export async function findParticipantById(id: string): Promise<Participant | undefined> {
const db = database();
return await db.query.seasonParticipants.findFirst({
where: eq(schema.seasonParticipants.id, id),
with: {
sportsSeason: {
with: {
sport: true,
},
},
},
});
}
export async function findParticipantByName(
sportsSeasonId: string,
name: string
): Promise<Participant | undefined> {
const db = database();
const results = await db
.select()
.from(schema.seasonParticipants)
.where(
and(
eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId),
sql`lower(${schema.seasonParticipants.name}) = lower(${name})`
)
)
.limit(1);
return results[0];
}
export async function findParticipantsBySportsSeasonId(sportsSeasonId: string): Promise<Participant[]> {
const db = database();
return await db.query.seasonParticipants.findMany({
where: eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId),
orderBy: (participants, { asc: orderAsc }) => [orderAsc(participants.name)],
});
}
export async function findParticipantsByExternalId(
externalId: string
): Promise<Participant[]> {
const db = database();
return await db.query.seasonParticipants.findMany({
where: eq(schema.seasonParticipants.externalId, externalId),
with: {
sportsSeason: {
with: {
sport: true,
},
},
},
});
}
export async function updateParticipant(
id: string,
data: Partial<NewParticipant>
): Promise<Participant> {
const db = database();
const [participant] = await db
.update(schema.seasonParticipants)
.set({ ...data, updatedAt: new Date() })
.where(eq(schema.seasonParticipants.id, id))
.returning();
return participant;
}
export async function countAllParticipants(): Promise<number> {
const db = database();
const result = await db.select({ value: count() }).from(schema.seasonParticipants);
return result[0].value;
}
export async function deleteParticipant(id: string): Promise<void> {
const db = database();
await db.delete(schema.seasonParticipants).where(eq(schema.seasonParticipants.id, id));
}
export async function copyParticipantsFromSeason(
sourceSportsSeasonId: string,
targetSportsSeasonId: string
): Promise<Participant[]> {
// Get all participants from source season
const sourceParticipants = await findParticipantsBySportsSeasonId(sourceSportsSeasonId);
// Insert them into the target season
if (sourceParticipants.length === 0) {
return [];
}
return await createManyParticipants(
sourceParticipants.map((p) => ({
sportsSeasonId: targetSportsSeasonId,
name: p.name,
shortName: p.shortName,
externalId: p.externalId,
expectedValue: p.expectedValue,
}))
);
}
/**
* Get all participants for a season with sport information
* Used for draft eligibility calculations
*/
export async function getParticipantsForSeasonWithSports(seasonId: string, providedDb?: ReturnType<typeof database>) {
const db = providedDb || database();
// First get all sports seasons for this season
const seasonSports = await db.query.seasonSports.findMany({
where: eq(schema.seasonSports.seasonId, seasonId),
});
if (seasonSports.length === 0) {
return [];
}
const sportsSeasonIds = seasonSports.map((ss) => ss.sportsSeasonId);
// Get all participants for these sports seasons with sport info
const participants = await db
.select({
id: schema.seasonParticipants.id,
name: schema.seasonParticipants.name,
sport: {
id: schema.sports.id,
name: schema.sports.name,
},
})
.from(schema.seasonParticipants)
.innerJoin(
schema.sportsSeasons,
eq(schema.seasonParticipants.sportsSeasonId, schema.sportsSeasons.id)
)
.innerJoin(
schema.sports,
eq(schema.sportsSeasons.sportId, schema.sports.id)
)
.where(
sportsSeasonIds.length === 1
? eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonIds[0])
: inArray(schema.seasonParticipants.sportsSeasonId, sportsSeasonIds)
);
return participants;
}
export async function getDraftParticipants(seasonId: string) {
const db = database();
const seasonSports = await db.query.seasonSports.findMany({
where: eq(schema.seasonSports.seasonId, seasonId),
});
const sportsSeasonIds = seasonSports.map((ss) => ss.sportsSeasonId);
if (sportsSeasonIds.length === 0) return [];
return await db
.select({
id: schema.seasonParticipants.id,
name: schema.seasonParticipants.name,
vorpValue: schema.seasonParticipants.vorpValue,
sport: schema.sports,
})
.from(schema.seasonParticipants)
.innerJoin(schema.sportsSeasons, eq(schema.seasonParticipants.sportsSeasonId, schema.sportsSeasons.id))
.innerJoin(schema.sports, eq(schema.sportsSeasons.sportId, schema.sports.id))
.where(
sportsSeasonIds.length === 1
? eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonIds[0])
: inArray(schema.seasonParticipants.sportsSeasonId, sportsSeasonIds)
)
.orderBy(desc(schema.seasonParticipants.vorpValue), asc(schema.seasonParticipants.name));
}

View file

@ -211,8 +211,8 @@ export async function cloneSportsSeason(
};
// Read all source data before writing anything
const sourceParticipants = await db.query.participants.findMany({
where: eq(schema.participants.sportsSeasonId, sourceId),
const sourceParticipants = await db.query.seasonParticipants.findMany({
where: eq(schema.seasonParticipants.sportsSeasonId, sourceId),
});
const sourceEvents = await db.query.scoringEvents.findMany({
@ -225,8 +225,8 @@ export async function cloneSportsSeason(
: null;
// Read source futures odds and Elo ratings
const sourceEvRows = await db.query.participantExpectedValues.findMany({
where: eq(schema.participantExpectedValues.sportsSeasonId, sourceId),
const sourceEvRows = await db.query.seasonParticipantExpectedValues.findMany({
where: eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sourceId),
});
// Build lookup: (externalId ?? name) → source EV, only for rows that have
@ -251,7 +251,7 @@ export async function cloneSportsSeason(
const newParticipants =
sourceParticipants.length > 0
? await tx
.insert(schema.participants)
.insert(schema.seasonParticipants)
.values(
sourceParticipants.map((p: typeof sourceParticipants[number]) => ({
sportsSeasonId: newSeason.id,
@ -288,7 +288,7 @@ export async function cloneSportsSeason(
.filter((r): r is NonNullable<typeof r> => r !== null);
if (evRows.length > 0) {
await tx.insert(schema.participantExpectedValues).values(evRows);
await tx.insert(schema.seasonParticipantExpectedValues).values(evRows);
}
}

View file

@ -7,7 +7,7 @@
*/
import { database } from "~/database/context";
import { participantSurfaceElos, participants } from "~/database/schema";
import { seasonParticipantSurfaceElos, seasonParticipants } from "~/database/schema";
import { eq, sql } from "drizzle-orm";
export type CourtSurface = "hard" | "clay" | "grass";
@ -38,7 +38,7 @@ export interface SurfaceEloInput {
/**
* Get all surface Elo records for a sports season, joined with participant names.
* Returns one record per participant (participants with no Elo record are excluded).
* Returns one record per participant (seasonParticipants with no Elo record are excluded).
*/
export async function getSurfaceElosForSeason(
sportsSeasonId: string
@ -46,26 +46,26 @@ export async function getSurfaceElosForSeason(
const db = database();
const rows = await db
.select({
id: participantSurfaceElos.id,
participantId: participantSurfaceElos.participantId,
sportsSeasonId: participantSurfaceElos.sportsSeasonId,
worldRanking: participantSurfaceElos.worldRanking,
eloHard: participantSurfaceElos.eloHard,
eloClay: participantSurfaceElos.eloClay,
eloGrass: participantSurfaceElos.eloGrass,
updatedAt: participantSurfaceElos.updatedAt,
participantName: participants.name,
id: seasonParticipantSurfaceElos.id,
participantId: seasonParticipantSurfaceElos.participantId,
sportsSeasonId: seasonParticipantSurfaceElos.sportsSeasonId,
worldRanking: seasonParticipantSurfaceElos.worldRanking,
eloHard: seasonParticipantSurfaceElos.eloHard,
eloClay: seasonParticipantSurfaceElos.eloClay,
eloGrass: seasonParticipantSurfaceElos.eloGrass,
updatedAt: seasonParticipantSurfaceElos.updatedAt,
participantName: seasonParticipants.name,
})
.from(participantSurfaceElos)
.innerJoin(participants, eq(participantSurfaceElos.participantId, participants.id))
.where(eq(participantSurfaceElos.sportsSeasonId, sportsSeasonId))
.orderBy(participants.name);
.from(seasonParticipantSurfaceElos)
.innerJoin(seasonParticipants, eq(seasonParticipantSurfaceElos.participantId, seasonParticipants.id))
.where(eq(seasonParticipantSurfaceElos.sportsSeasonId, sportsSeasonId))
.orderBy(seasonParticipants.name);
return rows;
}
/**
* Upsert surface Elo ratings for a batch of participants.
* Upsert surface Elo ratings for a batch of seasonParticipants.
* Uses INSERT ... ON CONFLICT DO UPDATE so all three surface columns are
* overwritten atomically the admin always submits all three values.
*/
@ -77,7 +77,7 @@ export async function batchUpsertSurfaceElos(
const now = new Date();
await db
.insert(participantSurfaceElos)
.insert(seasonParticipantSurfaceElos)
.values(
inputs.map(({ participantId, sportsSeasonId, worldRanking, eloHard, eloClay, eloGrass }) => ({
participantId,
@ -90,7 +90,7 @@ export async function batchUpsertSurfaceElos(
}))
)
.onConflictDoUpdate({
target: [participantSurfaceElos.participantId, participantSurfaceElos.sportsSeasonId],
target: [seasonParticipantSurfaceElos.participantId, seasonParticipantSurfaceElos.sportsSeasonId],
set: {
worldRanking: sql`excluded.world_ranking`,
eloHard: sql`excluded.elo_hard`,
@ -111,14 +111,14 @@ export async function getSurfaceEloMap(
const db = database();
const rows = await db
.select({
participantId: participantSurfaceElos.participantId,
worldRanking: participantSurfaceElos.worldRanking,
eloHard: participantSurfaceElos.eloHard,
eloClay: participantSurfaceElos.eloClay,
eloGrass: participantSurfaceElos.eloGrass,
participantId: seasonParticipantSurfaceElos.participantId,
worldRanking: seasonParticipantSurfaceElos.worldRanking,
eloHard: seasonParticipantSurfaceElos.eloHard,
eloClay: seasonParticipantSurfaceElos.eloClay,
eloGrass: seasonParticipantSurfaceElos.eloGrass,
})
.from(participantSurfaceElos)
.where(eq(participantSurfaceElos.sportsSeasonId, sportsSeasonId));
.from(seasonParticipantSurfaceElos)
.where(eq(seasonParticipantSurfaceElos.sportsSeasonId, sportsSeasonId));
return new Map(rows.map((r) => [r.participantId, {
worldRanking: r.worldRanking,

View file

@ -230,8 +230,8 @@ export async function getRecentTeamScoreEvents(
const participantNameById = new Map<string, string>();
if (allParticipantIds.length > 0) {
const participantRows = await db.query.participants.findMany({
where: inArray(schema.participants.id, allParticipantIds),
const participantRows = await db.query.seasonParticipants.findMany({
where: inArray(schema.seasonParticipants.id, allParticipantIds),
columns: { id: true, name: true },
});
for (const p of participantRows) {

View file

@ -0,0 +1,61 @@
import { eq, and, asc } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
export type TournamentResult = typeof schema.tournamentResults.$inferSelect;
export type NewTournamentResult = typeof schema.tournamentResults.$inferInsert;
export async function upsertTournamentResult(
data: NewTournamentResult
): Promise<TournamentResult> {
if (!data.tournamentId || !data.participantId) {
throw new Error("tournamentId and participantId are required");
}
const db = database();
const [result] = await db
.insert(schema.tournamentResults)
.values(data)
.onConflictDoUpdate({
target: [
schema.tournamentResults.tournamentId,
schema.tournamentResults.participantId,
],
set: {
placement: data.placement,
rawScore: data.rawScore,
updatedAt: new Date(),
},
})
.returning();
return result;
}
export async function getTournamentResults(
tournamentId: string
): Promise<TournamentResult[]> {
const db = database();
return await db
.select()
.from(schema.tournamentResults)
.where(eq(schema.tournamentResults.tournamentId, tournamentId))
.orderBy(asc(schema.tournamentResults.placement));
}
export async function getTournamentResultByParticipant(
tournamentId: string,
participantId: string
): Promise<TournamentResult | null> {
const db = database();
const results = await db
.select()
.from(schema.tournamentResults)
.where(
and(
eq(schema.tournamentResults.tournamentId, tournamentId),
eq(schema.tournamentResults.participantId, participantId)
)
)
.limit(1);
return results[0] ?? null;
}

94
app/models/tournament.ts Normal file
View file

@ -0,0 +1,94 @@
import { eq, and, asc } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
export type Tournament = typeof schema.tournaments.$inferSelect;
export type NewTournament = typeof schema.tournaments.$inferInsert;
export type TournamentStatus = Tournament["status"];
export async function createTournament(
data: NewTournament
): Promise<Tournament> {
const db = database();
const [tournament] = await db
.insert(schema.tournaments)
.values(data)
.returning();
return tournament;
}
export async function getTournamentById(
id: string
): Promise<Tournament | null> {
const db = database();
const results = await db
.select()
.from(schema.tournaments)
.where(eq(schema.tournaments.id, id))
.limit(1);
return results[0] ?? null;
}
export async function findTournamentsBySport(
sportId: string
): Promise<Tournament[]> {
const db = database();
return await db
.select()
.from(schema.tournaments)
.where(eq(schema.tournaments.sportId, sportId))
.orderBy(asc(schema.tournaments.year), asc(schema.tournaments.startsAt));
}
export async function findTournamentBySportNameYear(
sportId: string,
name: string,
year: number
): Promise<Tournament | null> {
const db = database();
const results = await db
.select()
.from(schema.tournaments)
.where(
and(
eq(schema.tournaments.sportId, sportId),
eq(schema.tournaments.name, name),
eq(schema.tournaments.year, year)
)
)
.limit(1);
return results[0] ?? null;
}
export async function upsertTournament(
data: NewTournament
): Promise<Tournament> {
if (!data.sportId || !data.name || !data.year) {
throw new Error("sportId, name, and year are required for upsert");
}
const existing = await findTournamentBySportNameYear(
data.sportId,
data.name,
data.year
);
if (existing) {
return existing;
}
return await createTournament(data);
}
export async function updateTournamentStatus(
id: string,
status: TournamentStatus
): Promise<Tournament> {
const db = database();
const [tournament] = await db
.update(schema.tournaments)
.set({ status, updatedAt: new Date() })
.where(eq(schema.tournaments.id, id))
.returning();
return tournament;
}

View file

@ -6,7 +6,7 @@ import { logger } from "~/lib/logger";
import { findAllSports } from "~/models/sport";
import { findAllSportsSeasons } from "~/models/sports-season";
import { findAllSeasonTemplates } from "~/models/season-template";
import { countAllParticipants } from "~/models/participant";
import { countAllParticipants } from "~/models/season-participant";
import { countAllParticipantEVs } from "~/models/participant-expected-value";
import { importSportsDataFromJSON } from "~/utils/sports-data-sync.server";
import { Button } from "~/components/ui/button";

View file

@ -3,7 +3,7 @@ import type { Route } from './+types/admin.sports-seasons.$id.elo-ratings';
import { logger } from '~/lib/logger';
import { findSportsSeasonById, updateSportsSeason } from '~/models/sports-season';
import { findParticipantsBySportsSeasonId } from '~/models/participant';
import { findParticipantsBySportsSeasonId } from '~/models/season-participant';
import {
getAllParticipantEVsForSeason,
batchSaveSourceElos,

View file

@ -1,7 +1,7 @@
import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket";
import { findSportsSeasonById } from "~/models/sports-season";
import { findParticipantsBySportsSeasonId } from "~/models/participant";
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
import { getScoringEventById, updateScoringEvent } from "~/models/scoring-event";
import {
findPlayoffMatchesByEventId,
@ -595,9 +595,9 @@ export async function action({ request, params }: Route.ActionArgs) {
// the "never un-finalize" guard in upsertParticipantResult.
const db = database();
await db
.delete(schema.participantResults)
.delete(schema.seasonParticipantResults)
.where(
eq(schema.participantResults.sportsSeasonId, event.sportsSeasonId)
eq(schema.seasonParticipantResults.sportsSeasonId, event.sportsSeasonId)
);
// Replay each completed match in bracket order (earlier rounds first).

View file

@ -3,7 +3,7 @@ import type { Route } from './+types/admin.sports-seasons.$id.events.$eventId.cs
import { findSportsSeasonById } from '~/models/sports-season';
import { getScoringEventById } from '~/models/scoring-event';
import { findParticipantsBySportsSeasonId } from '~/models/participant';
import { findParticipantsBySportsSeasonId } from '~/models/season-participant';
import {
getCs2StageResultsForEvent,
upsertCs2StageAssignments,

View file

@ -1,7 +1,7 @@
import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId";
import { logger } from "~/lib/logger";
import { findSportsSeasonById } from "~/models/sports-season";
import { findParticipantsBySportsSeasonId, createParticipant } from "~/models/participant";
import { findParticipantsBySportsSeasonId, createParticipant } from "~/models/season-participant";
import {
getScoringEventById,
completeScoringEvent,
@ -134,7 +134,7 @@ export async function action({ request, params }: Route.ActionArgs) {
// This marks them as "competed, earned nothing" so simulations skip this event.
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
const existingResults = await getEventResults(params.eventId);
const existingIds = new Set(existingResults.map((r) => r.participantId));
const existingIds = new Set(existingResults.map((r) => r.seasonParticipantId));
const missingIds = findParticipantsWithoutResults(
allParticipants.map((p) => p.id),
existingIds
@ -373,7 +373,7 @@ export async function action({ request, params }: Route.ActionArgs) {
try {
const existingResults = await getEventResults(params.eventId);
const existingIds = new Set(existingResults.map((r) => r.participantId));
const existingIds = new Set(existingResults.map((r) => r.seasonParticipantId));
const newResults = filterNewResults(incoming, existingIds);
if (newResults.length > 0) {

View file

@ -99,7 +99,7 @@ export default function EventResults({
// Create a map of participants with results for easy lookup
const participantResultsMap = new Map(
results.map((r: { participant: { id: string } }) => [r.participant.id, r])
results.map((r: { seasonParticipant: { id: string } }) => [r.seasonParticipant.id, r])
);
// Get participants without results
@ -419,7 +419,7 @@ export default function EventResults({
participants={participants}
sportsSeasonId={sportsSeason.id}
existingResultParticipantIds={
new Set(results.map((r: { participant: { id: string } }) => r.participant.id))
new Set(results.map((r: { seasonParticipant: { id: string } }) => r.seasonParticipant.id))
}
/>
)}
@ -576,7 +576,7 @@ export default function EventResults({
</span>
</div>
</TableCell>
<TableCell>{result.participant.name}</TableCell>
<TableCell>{result.seasonParticipant.name}</TableCell>
{event.isQualifyingEvent && event.isComplete && (
<TableCell className="text-right">
{result.qualifyingPointsAwarded ? (
@ -620,7 +620,7 @@ export default function EventResults({
variant="ghost"
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
onClick={(e) => {
if (!confirm(`Delete result for ${result.participant.name}?`)) {
if (!confirm(`Delete result for ${result.seasonParticipant.name}?`)) {
e.preventDefault();
}
}}

View file

@ -1,7 +1,7 @@
import type { Route } from "./+types/admin.sports-seasons.$id.expected-values";
import { logger } from "~/lib/logger";
import { findSportsSeasonById } from "~/models/sports-season";
import { findParticipantsBySportsSeasonId } from "~/models/participant";
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
import {
batchUpsertParticipantEVs,
getAllParticipantEVsForSeason

View file

@ -3,7 +3,7 @@ import type { Route } from './+types/admin.sports-seasons.$id.futures-odds';
import { logger } from '~/lib/logger';
import { findSportsSeasonById, updateSportsSeason } from '~/models/sports-season';
import { findParticipantsBySportsSeasonId } from '~/models/participant';
import { findParticipantsBySportsSeasonId } from '~/models/season-participant';
import { getAllParticipantEVsForSeason, batchSaveSourceOdds, batchUpsertParticipantEVs } from '~/models/participant-expected-value';
import { batchUpsertParticipantEvSnapshots } from '~/models/ev-snapshot';
import { getSimulator, type SimulatorType } from '~/services/simulations/registry';

View file

@ -3,7 +3,7 @@ import type { Route } from './+types/admin.sports-seasons.$id.golf-skills';
import { logger } from '~/lib/logger';
import { findSportsSeasonById, updateSportsSeason } from '~/models/sports-season';
import { findParticipantsBySportsSeasonId, findParticipantByName, createParticipant } from '~/models/participant';
import { findParticipantsBySportsSeasonId, findParticipantByName, createParticipant } from '~/models/season-participant';
import { batchUpsertParticipantEVs } from '~/models/participant-expected-value';
import { batchUpsertParticipantEvSnapshots } from '~/models/ev-snapshot';
import { getGolfSkillsForSeason, batchUpsertGolfSkills } from '~/models/golf-skills';

View file

@ -10,7 +10,7 @@ import {
createParticipant,
deleteParticipant,
updateParticipant,
} from "~/models/participant";
} from "~/models/season-participant";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";

View file

@ -3,7 +3,7 @@ import type { Route } from "./+types/admin.sports-seasons.$id.regular-standings"
import { logger } from "~/lib/logger";
import { findSportsSeasonById } from "~/models/sports-season";
import { findParticipantsBySportsSeasonId } from "~/models/participant";
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
import { getRegularSeasonStandings, upsertManualStanding } from "~/models/regular-season-standings";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";

View file

@ -15,7 +15,7 @@ import type { Route } from "./+types/admin.sports-seasons.$id.simulate";
import { findSportsSeasonById, updateSportsSeason } from "~/models/sports-season";
import { batchUpsertParticipantEVs } from "~/models/participant-expected-value";
import { batchUpsertParticipantEvSnapshots } from "~/models/ev-snapshot";
import { findParticipantsBySportsSeasonId } from "~/models/participant";
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
import { getSimulator, type SimulatorType } from "~/services/simulations/registry";
import { calculateEV } from "~/services/ev-calculator";
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";

View file

@ -3,7 +3,7 @@ import type { Route } from "./+types/admin.sports-seasons.$id.standings";
import { logger } from "~/lib/logger";
import { findSportsSeasonById } from "~/models/sports-season";
import { findParticipantsBySportsSeasonId } from "~/models/participant";
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
import { getSeasonResults, upsertSeasonResultsBulk } from "~/models/participant-season-result";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";

View file

@ -3,7 +3,7 @@ import type { Route } from './+types/admin.sports-seasons.$id.surface-elo';
import { logger } from '~/lib/logger';
import { findSportsSeasonById, updateSportsSeason } from '~/models/sports-season';
import { findParticipantsBySportsSeasonId, findParticipantByName, createParticipant } from '~/models/participant';
import { findParticipantsBySportsSeasonId, findParticipantByName, createParticipant } from '~/models/season-participant';
import {
batchUpsertParticipantEVs,
} from '~/models/participant-expected-value';

View file

@ -16,7 +16,7 @@ import {
getPendingStandingsMappings,
deletePendingStandingsMapping,
} from "~/models/pending-standings-mappings";
import { findParticipantsBySportsSeasonId, updateParticipant } from "~/models/participant";
import { findParticipantsBySportsSeasonId, updateParticipant } from "~/models/season-participant";
import { getLastSyncedAt, upsertRegularSeasonStandings } from "~/models/regular-season-standings";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";

View file

@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("~/models/participant", () => ({
vi.mock("~/models/season-participant", () => ({
findParticipantsBySportsSeasonId: vi.fn(),
findParticipantByName: vi.fn(),
createParticipant: vi.fn(),
@ -19,7 +19,7 @@ vi.mock("~/lib/logger", () => ({
import {
findParticipantByName,
updateParticipant,
} from "~/models/participant";
} from "~/models/season-participant";
import { action } from "~/routes/admin.sports-seasons.$id.participants";
const mockParams = { id: "season-1" };

View file

@ -16,7 +16,7 @@ vi.mock("~/models/draft-pick", () => ({
getDraftPicksWithSports: vi.fn(),
getTeamDraftPicksWithSports: vi.fn(),
}));
vi.mock("~/models/participant", () => ({
vi.mock("~/models/season-participant", () => ({
getParticipantsForSeasonWithSports: vi.fn(),
}));
vi.mock("~/models/season-sport", () => ({
@ -138,7 +138,7 @@ describe("draft.force-manual-pick action", () => {
vi.mocked(getDraftPicksWithSports).mockResolvedValue([]);
vi.mocked(getTeamDraftPicksWithSports).mockResolvedValue([]);
const { getParticipantsForSeasonWithSports } = await import("~/models/participant");
const { getParticipantsForSeasonWithSports } = await import("~/models/season-participant");
vi.mocked(getParticipantsForSeasonWithSports).mockResolvedValue([]);
const { getSeasonSportsSimple } = await import("~/models/season-sport");
@ -159,7 +159,7 @@ describe("draft.force-manual-pick action", () => {
seasons: { findFirst: vi.fn() },
commissioners: { findFirst: vi.fn() },
draftPicks: { findFirst: vi.fn() },
participants: { findFirst: vi.fn() },
seasonParticipants: { findFirst: vi.fn() },
draftSlots: { findMany: vi.fn() },
draftTimers: { findFirst: vi.fn() },
},
@ -178,7 +178,7 @@ describe("draft.force-manual-pick action", () => {
userId: COMMISSIONER_ID,
});
mockDb.query.draftPicks.findFirst.mockResolvedValue(null);
mockDb.query.participants.findFirst.mockResolvedValue(mockParticipant);
mockDb.query.seasonParticipants.findFirst.mockResolvedValue(mockParticipant);
mockDb.query.draftSlots.findMany.mockResolvedValue(mockDraftSlots);
mockDb.query.draftTimers.findFirst.mockResolvedValue({
id: "timer-1",
@ -277,7 +277,7 @@ describe("draft.force-manual-pick action", () => {
});
it("returns 404 when the participant does not exist", async () => {
mockDb.query.participants.findFirst.mockResolvedValue(null);
mockDb.query.seasonParticipants.findFirst.mockResolvedValue(null);
const response = await action({ request: defaultPickRequest(), params: {}, context: ctx });

View file

@ -22,7 +22,7 @@ vi.mock("~/models/draft-pick", () => ({
getDraftPicksWithSports: vi.fn(),
getTeamDraftPicksWithSports: vi.fn(),
}));
vi.mock("~/models/participant", () => ({
vi.mock("~/models/season-participant", () => ({
getParticipantsForSeasonWithSports: vi.fn(),
}));
vi.mock("~/models/season-sport", () => ({
@ -141,7 +141,7 @@ describe("draft.force-manual-pick action — timer mode behavior", () => {
vi.mocked(getDraftPicksWithSports).mockResolvedValue([]);
vi.mocked(getTeamDraftPicksWithSports).mockResolvedValue([]);
const { getParticipantsForSeasonWithSports } = await import("~/models/participant");
const { getParticipantsForSeasonWithSports } = await import("~/models/season-participant");
vi.mocked(getParticipantsForSeasonWithSports).mockResolvedValue([]);
const { getSeasonSportsSimple } = await import("~/models/season-sport");
@ -163,7 +163,7 @@ describe("draft.force-manual-pick action — timer mode behavior", () => {
findFirst: vi.fn().mockResolvedValue({ id: "c-1", userId: COMMISSIONER_ID }),
},
draftPicks: { findFirst: vi.fn().mockResolvedValue(null) },
participants: { findFirst: vi.fn().mockResolvedValue(mockParticipant) },
seasonParticipants: { findFirst: vi.fn().mockResolvedValue(mockParticipant) },
draftSlots: { findMany: vi.fn().mockResolvedValue(mockDraftSlots) },
draftTimers: {
findFirst: vi.fn().mockResolvedValue({ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 75 }),

View file

@ -22,7 +22,7 @@ vi.mock("~/models/draft-pick", () => ({
getDraftPicksWithSports: vi.fn(),
getTeamDraftPicksWithSports: vi.fn(),
}));
vi.mock("~/models/participant", () => ({
vi.mock("~/models/season-participant", () => ({
getParticipantsForSeasonWithSports: vi.fn(),
}));
vi.mock("~/models/season-sport", () => ({
@ -138,7 +138,7 @@ describe("draft.make-pick action — timer mode behavior", () => {
vi.mocked(getDraftPicksWithSports).mockResolvedValue([]);
vi.mocked(getTeamDraftPicksWithSports).mockResolvedValue([]);
const { getParticipantsForSeasonWithSports } = await import("~/models/participant");
const { getParticipantsForSeasonWithSports } = await import("~/models/season-participant");
vi.mocked(getParticipantsForSeasonWithSports).mockResolvedValue([]);
const { getSeasonSportsSimple } = await import("~/models/season-sport");
@ -158,7 +158,7 @@ describe("draft.make-pick action — timer mode behavior", () => {
seasons: { findFirst: vi.fn() },
commissioners: { findFirst: vi.fn().mockResolvedValue(null) },
draftPicks: { findFirst: vi.fn().mockResolvedValue(null) },
participants: { findFirst: vi.fn().mockResolvedValue(mockParticipant) },
seasonParticipants: { findFirst: vi.fn().mockResolvedValue(mockParticipant) },
draftSlots: { findMany: vi.fn().mockResolvedValue(mockDraftSlots) },
draftTimers: { findFirst: vi.fn().mockResolvedValue({ id: "timer-1", timeRemaining: 75 }) },
draftQueue: { findMany: vi.fn().mockResolvedValue([]) },

View file

@ -5,7 +5,7 @@ import { eq, and, sql } from "drizzle-orm";
import { isUserAdmin } from "~/models/user";
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/draft-pick";
import { getParticipantsForSeasonWithSports } from "~/models/participant";
import { getParticipantsForSeasonWithSports } from "~/models/season-participant";
import { getSeasonSportsSimple } from "~/models/season-sport";
import { calculatePickInfo, checkAndTriggerNextAutodraft } from "~/models/draft-utils";
import { logCommissionerAction } from "~/models/audit-log";
@ -87,8 +87,8 @@ export async function action(args: ActionFunctionArgs) {
}
// Get participant details
const participant = await db.query.participants.findFirst({
where: eq(schema.participants.id, participantId),
const participant = await db.query.seasonParticipants.findFirst({
where: eq(schema.seasonParticipants.id, participantId),
with: {
sportsSeason: {
with: {

View file

@ -5,7 +5,7 @@ import { eq, and, sql } from "drizzle-orm";
import { isUserAdmin } from "~/models/user";
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/draft-pick";
import { getParticipantsForSeasonWithSports } from "~/models/participant";
import { getParticipantsForSeasonWithSports } from "~/models/season-participant";
import { getSeasonSportsSimple } from "~/models/season-sport";
import { calculatePickInfo, checkAndTriggerNextAutodraft, pruneIneligibleQueueItems } from "~/models/draft-utils";
import { getSocketIO, scheduleDraftRoomClosure } from "../../../server/socket";
@ -96,8 +96,8 @@ export async function action(args: ActionFunctionArgs) {
}
// Get participant details
const participant = await db.query.participants.findFirst({
where: eq(schema.participants.id, participantId),
const participant = await db.query.seasonParticipants.findFirst({
where: eq(schema.seasonParticipants.id, participantId),
with: {
sportsSeason: {
with: {

View file

@ -5,7 +5,7 @@ import { eq, and } from "drizzle-orm";
import { isCommissioner } from "~/models/commissioner";
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/draft-pick";
import { getParticipantsForSeasonWithSports } from "~/models/participant";
import { getParticipantsForSeasonWithSports } from "~/models/season-participant";
import { getSeasonSportsSimple } from "~/models/season-sport";
import { logCommissionerAction } from "~/models/audit-log";
import { getSocketIO } from "../../../server/socket";
@ -78,8 +78,8 @@ export async function action(args: ActionFunctionArgs) {
}
}
const participant = await db.query.participants.findFirst({
where: eq(schema.participants.id, participantId),
const participant = await db.query.seasonParticipants.findFirst({
where: eq(schema.seasonParticipants.id, participantId),
with: {
sportsSeason: { with: { sport: true } },
},
@ -128,8 +128,8 @@ export async function action(args: ActionFunctionArgs) {
}
// Fetch old participant name for the audit log (before overwriting the pick)
const oldParticipant = await db.query.participants.findFirst({
where: eq(schema.participants.id, oldParticipantId),
const oldParticipant = await db.query.seasonParticipants.findFirst({
where: eq(schema.seasonParticipants.id, oldParticipantId),
});
// Update the pick in-place

View file

@ -51,13 +51,13 @@ export async function loader({ params }: LoaderFunctionArgs) {
round: schema.draftPicks.round,
teamName: schema.teams.name,
teamOwnerId: schema.teams.ownerId,
participantName: schema.participants.name,
participantName: schema.seasonParticipants.name,
sport: schema.sports.name,
})
.from(schema.draftPicks)
.innerJoin(schema.teams, eq(schema.draftPicks.teamId, schema.teams.id))
.innerJoin(schema.participants, eq(schema.draftPicks.participantId, schema.participants.id))
.innerJoin(schema.sportsSeasons, eq(schema.participants.sportsSeasonId, schema.sportsSeasons.id))
.innerJoin(schema.seasonParticipants, eq(schema.draftPicks.participantId, schema.seasonParticipants.id))
.innerJoin(schema.sportsSeasons, eq(schema.seasonParticipants.sportsSeasonId, schema.sportsSeasons.id))
.innerJoin(schema.sports, eq(schema.sportsSeasons.sportId, schema.sports.id))
.where(eq(schema.draftPicks.seasonId, seasonId))
.orderBy(schema.draftPicks.pickNumber);

View file

@ -76,19 +76,19 @@ export async function loader(args: Route.LoaderArgs) {
round: schema.draftPicks.round,
pickInRound: schema.draftPicks.pickInRound,
team: schema.teams,
participant: schema.participants,
participant: schema.seasonParticipants,
sport: schema.sports,
scoringPattern: schema.sportsSeasons.scoringPattern,
})
.from(schema.draftPicks)
.innerJoin(schema.teams, eq(schema.draftPicks.teamId, schema.teams.id))
.innerJoin(
schema.participants,
eq(schema.draftPicks.participantId, schema.participants.id)
schema.seasonParticipants,
eq(schema.draftPicks.participantId, schema.seasonParticipants.id)
)
.innerJoin(
schema.sportsSeasons,
eq(schema.participants.sportsSeasonId, schema.sportsSeasons.id)
eq(schema.seasonParticipants.sportsSeasonId, schema.sportsSeasons.id)
)
.innerJoin(schema.sports, eq(schema.sportsSeasons.sportId, schema.sports.id))
.where(eq(schema.draftPicks.seasonId, seasonId))
@ -104,13 +104,13 @@ export async function loader(args: Route.LoaderArgs) {
const results = await db
.select({
participantId: schema.participantResults.participantId,
sportsSeasonId: schema.participantResults.sportsSeasonId,
finalPosition: schema.participantResults.finalPosition,
isPartialScore: schema.participantResults.isPartialScore,
participantId: schema.seasonParticipantResults.participantId,
sportsSeasonId: schema.seasonParticipantResults.sportsSeasonId,
finalPosition: schema.seasonParticipantResults.finalPosition,
isPartialScore: schema.seasonParticipantResults.isPartialScore,
})
.from(schema.participantResults)
.where(inArray(schema.participantResults.participantId, participantIds));
.from(schema.seasonParticipantResults)
.where(inArray(schema.seasonParticipantResults.participantId, participantIds));
const resultByParticipant = new Map(results.map((r) => [r.participantId, r]));

View file

@ -17,7 +17,7 @@ import { getTeamWatchlist } from "~/models/watchlist";
import { findSeasonWithTeamsAndLeague } from "~/models/season";
import { findDraftSlotsBySeasonId } from "~/models/draft-slot";
import { getDraftPicksForSeason } from "~/models/draft-pick";
import { getDraftParticipants } from "~/models/participant";
import { getDraftParticipants } from "~/models/season-participant";
import { getSeasonTimers } from "~/models/draft-timer";
import { getSeasonAutodraftSettings } from "~/models/autodraft-settings";
import { hasCommissionerRecord } from "~/models/commissioner";

View file

@ -233,15 +233,15 @@ export async function loader(args: Route.LoaderArgs) {
// Fetch group-stage losers and all participant results for bracket scoring
const [eliminatedResults, allResults] = await Promise.all([
db.query.participantResults.findMany({
db.query.seasonParticipantResults.findMany({
where: and(
eq(schema.participantResults.sportsSeasonId, sportsSeasonId),
eq(schema.participantResults.finalPosition, 0)
eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId),
eq(schema.seasonParticipantResults.finalPosition, 0)
),
with: { participant: true },
}),
db.query.participantResults.findMany({
where: eq(schema.participantResults.sportsSeasonId, sportsSeasonId),
db.query.seasonParticipantResults.findMany({
where: eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId),
}),
]);

View file

@ -252,8 +252,8 @@ export async function previewProbabilityUpdate(
const results = await findParticipantResultsBySportsSeasonId(sportsSeasonId);
// Get all existing EVs with participant names
const existingEVs = await db.query.participantExpectedValues.findMany({
where: eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId),
const existingEVs = await db.query.seasonParticipantExpectedValues.findMany({
where: eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId),
with: {
participant: true,
},

View file

@ -86,7 +86,7 @@ import { database } from "~/database/context";
const mockDb = {
query: {
participants: { findMany: vi.fn() },
seasonParticipants: { findMany: vi.fn() },
scoringEvents: { findFirst: vi.fn() },
tournamentGroups: { findMany: vi.fn() },
playoffMatches: { findMany: vi.fn() },
@ -98,7 +98,7 @@ const mockDb = {
beforeEach(() => {
vi.mocked(database).mockReturnValue(mockDb as never);
mockDb.query.participants.findMany.mockReset();
mockDb.query.seasonParticipants.findMany.mockReset();
mockDb.query.scoringEvents.findFirst.mockReset();
mockDb.query.tournamentGroups.findMany.mockReset();
mockDb.query.playoffMatches.findMany.mockReset();
@ -109,7 +109,7 @@ beforeEach(() => {
describe("WorldCupSimulator", () => {
it("throws when no participants are found", async () => {
mockDb.query.participants.findMany.mockResolvedValue([]);
mockDb.query.seasonParticipants.findMany.mockResolvedValue([]);
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
@ -120,7 +120,7 @@ describe("WorldCupSimulator", () => {
it("returns one result per participant", async () => {
const participants = makeParticipants(48);
mockDb.query.participants.findMany.mockResolvedValue(participants);
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
@ -136,7 +136,7 @@ describe("WorldCupSimulator", () => {
it("column sums for champion, runner-up, 3rd, 4th are each ≈1.0", async () => {
const participants = makeParticipants(48);
mockDb.query.participants.findMany.mockResolvedValue(participants);
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
@ -157,7 +157,7 @@ describe("WorldCupSimulator", () => {
it("probabilities are all non-negative", async () => {
const participants = makeParticipants(48);
mockDb.query.participants.findMany.mockResolvedValue(participants);
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
@ -178,7 +178,7 @@ describe("WorldCupSimulator", () => {
it("SF losers land in 3rd or 4th, never 1st or 2nd", async () => {
// Set up 8 participants (small bracket, 2 groups of 4)
const participants = makeParticipants(8);
mockDb.query.participants.findMany.mockResolvedValue(participants);
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
@ -200,7 +200,7 @@ describe("WorldCupSimulator", () => {
it("a team with pre-completed group stage result is fixed in simulation", async () => {
const participants = makeParticipants(48);
mockDb.query.participants.findMany.mockResolvedValue(participants);
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
mockDb.query.scoringEvents.findFirst.mockResolvedValue({ id: "event-1" });
// One group fully complete: p0 wins everything, p3 loses everything
@ -254,7 +254,7 @@ describe("WorldCupSimulator", () => {
it("probFifth through probEighth are equal for each participant (QF losers split evenly)", async () => {
const participants = makeParticipants(48);
mockDb.query.participants.findMany.mockResolvedValue(participants);
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);

View file

@ -198,16 +198,16 @@ export class AFLSimulator implements Simulator {
// 1. Load participants, DB Elo, and standings in parallel.
const [participantRows, evRows, standings] = await Promise.all([
db
.select({ id: schema.participants.id, name: schema.participants.name })
.from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId)),
.select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name })
.from(schema.seasonParticipants)
.where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId)),
db
.select({
participantId: schema.participantExpectedValues.participantId,
sourceElo: schema.participantExpectedValues.sourceElo,
participantId: schema.seasonParticipantExpectedValues.participantId,
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
})
.from(schema.participantExpectedValues)
.where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId)),
.from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)),
getRegularSeasonStandings(sportsSeasonId),
]);

View file

@ -103,8 +103,8 @@ export class AutoRacingSimulator implements Simulator {
const db = database();
// 1. Load all participants for this sports season
const participants = await db.query.participants.findMany({
where: eq(schema.participants.sportsSeasonId, sportsSeasonId),
const participants = await db.query.seasonParticipants.findMany({
where: eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId),
});
if (participants.length === 0) {

View file

@ -11,7 +11,7 @@
*/
import { database } from "~/database/context";
import { participantExpectedValues } from "~/database/schema";
import { seasonParticipantExpectedValues } from "~/database/schema";
import { eq } from "drizzle-orm";
import { simulateBracket } from "~/services/bracket-simulator";
import { convertFuturesToElo } from "~/services/probability-engine";
@ -24,12 +24,12 @@ export class BracketSimulator implements Simulator {
// Load all participants with their current EVs (which hold probability distributions)
const evRows = await db
.select({
participantId: participantExpectedValues.participantId,
probFirst: participantExpectedValues.probFirst,
sourceOdds: participantExpectedValues.sourceOdds,
participantId: seasonParticipantExpectedValues.participantId,
probFirst: seasonParticipantExpectedValues.probFirst,
sourceOdds: seasonParticipantExpectedValues.sourceOdds,
})
.from(participantExpectedValues)
.where(eq(participantExpectedValues.sportsSeasonId, sportsSeasonId));
.from(seasonParticipantExpectedValues)
.where(eq(seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
if (evRows.length === 0) {
throw new Error(

View file

@ -433,9 +433,9 @@ export class CSMajorSimulator implements Simulator {
// 1. Load all participants for this sports season.
const allParticipants = await db
.select({ id: schema.participants.id })
.from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId));
.select({ id: schema.seasonParticipants.id })
.from(schema.seasonParticipants)
.where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId));
const participantIds = allParticipants.map((p) => p.id);
@ -446,12 +446,12 @@ export class CSMajorSimulator implements Simulator {
// 2. Load Elo ratings and world rankings from participantExpectedValues.
const evRows = await db
.select({
participantId: schema.participantExpectedValues.participantId,
sourceElo: schema.participantExpectedValues.sourceElo,
worldRanking: schema.participantExpectedValues.worldRanking,
participantId: schema.seasonParticipantExpectedValues.participantId,
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
worldRanking: schema.seasonParticipantExpectedValues.worldRanking,
})
.from(schema.participantExpectedValues)
.where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId));
.from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
const eloMap = new Map<string, { elo: number; rank: number }>();
for (const row of evRows) {
@ -508,7 +508,7 @@ export class CSMajorSimulator implements Simulator {
if (completedEventIds.length > 0) {
const actualResults = await db
.select({
participantId: schema.eventResults.participantId,
participantId: schema.eventResults.seasonParticipantId,
qualifyingPointsAwarded: schema.eventResults.qualifyingPointsAwarded,
})
.from(schema.eventResults)

View file

@ -220,12 +220,12 @@ export class DartsSimulator implements Simulator {
// 3. Load Elo ratings and world rankings.
const evRows = await db
.select({
participantId: schema.participantExpectedValues.participantId,
sourceElo: schema.participantExpectedValues.sourceElo,
worldRanking: schema.participantExpectedValues.worldRanking,
participantId: schema.seasonParticipantExpectedValues.participantId,
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
worldRanking: schema.seasonParticipantExpectedValues.worldRanking,
})
.from(schema.participantExpectedValues)
.where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId));
.from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
const eloMap = new Map<string, number>();
const rankingMap = new Map<string, number>();
@ -452,9 +452,9 @@ export class DartsSimulator implements Simulator {
db: ReturnType<typeof database>
): Promise<SimulationResult[]> {
const allParticipants = await db
.select({ id: schema.participants.id, name: schema.participants.name })
.from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId));
.select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name })
.from(schema.seasonParticipants)
.where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId));
if (allParticipants.length < 2) {
throw new Error(

View file

@ -185,9 +185,9 @@ export class GolfSimulator implements Simulator {
// Load participants, skills, QP config, and scoring events in parallel.
const [allParticipants, skillsMap, qpConfigRows, events] = await Promise.all([
db
.select({ id: schema.participants.id })
.from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId)),
.select({ id: schema.seasonParticipants.id })
.from(schema.seasonParticipants)
.where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId)),
getGolfSkillsMap(sportsSeasonId),
getQPConfig(sportsSeasonId),
db.query.scoringEvents.findMany({
@ -226,7 +226,7 @@ export class GolfSimulator implements Simulator {
if (completedEventIds.length > 0) {
const actualResults = await db
.select({
participantId: schema.eventResults.participantId,
participantId: schema.eventResults.seasonParticipantId,
qualifyingPointsAwarded: schema.eventResults.qualifyingPointsAwarded,
})
.from(schema.eventResults)

View file

@ -264,9 +264,9 @@ export class LLWSSimulator implements Simulator {
// 1. Load all participants.
const participants = await db
.select({ id: schema.participants.id, name: schema.participants.name, externalId: schema.participants.externalId })
.from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId));
.select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name, externalId: schema.seasonParticipants.externalId })
.from(schema.seasonParticipants)
.where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId));
if (participants.length !== US_TEAM_COUNT + INTL_TEAM_COUNT) {
throw new Error(
@ -278,11 +278,11 @@ export class LLWSSimulator implements Simulator {
// 2. Load championship futures odds.
const evRows = await db
.select({
participantId: schema.participantExpectedValues.participantId,
sourceOdds: schema.participantExpectedValues.sourceOdds,
participantId: schema.seasonParticipantExpectedValues.participantId,
sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds,
})
.from(schema.participantExpectedValues)
.where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId));
.from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
const rawOddsMap = new Map<string, number>();
for (const row of evRows) {

View file

@ -409,9 +409,9 @@ export class MLBSimulator implements Simulator {
// 1. Load all participants for this sports season.
const participantRows = await db
.select({ id: schema.participants.id, name: schema.participants.name })
.from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId));
.select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name })
.from(schema.seasonParticipants)
.where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId));
if (participantRows.length === 0) {
throw new Error(
@ -452,12 +452,12 @@ export class MLBSimulator implements Simulator {
const evRows = await db
.select({
participantId: schema.participantExpectedValues.participantId,
sourceOdds: schema.participantExpectedValues.sourceOdds,
sourceElo: schema.participantExpectedValues.sourceElo,
participantId: schema.seasonParticipantExpectedValues.participantId,
sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds,
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
})
.from(schema.participantExpectedValues)
.where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId));
.from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
const participantIdSet = new Set(participantIds);
const oddsRows = evRows.filter(

View file

@ -312,16 +312,16 @@ export class NBASimulator implements Simulator {
// Load participant names and sourceElo values in parallel.
const [participantRows, evRows] = await Promise.all([
db
.select({ id: schema.participants.id, name: schema.participants.name })
.from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId)),
.select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name })
.from(schema.seasonParticipants)
.where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId)),
db
.select({
participantId: schema.participantExpectedValues.participantId,
sourceElo: schema.participantExpectedValues.sourceElo,
participantId: schema.seasonParticipantExpectedValues.participantId,
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
})
.from(schema.participantExpectedValues)
.where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId)),
.from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)),
]);
const nameById = new Map(participantRows.map((r) => [r.id, r.name]));
@ -472,17 +472,17 @@ export class NBASimulator implements Simulator {
const [participantRows, standings, evRows] = await Promise.all([
db
.select({ id: schema.participants.id, name: schema.participants.name })
.from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId)),
.select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name })
.from(schema.seasonParticipants)
.where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId)),
getRegularSeasonStandings(sportsSeasonId),
db
.select({
participantId: schema.participantExpectedValues.participantId,
sourceElo: schema.participantExpectedValues.sourceElo,
participantId: schema.seasonParticipantExpectedValues.participantId,
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
})
.from(schema.participantExpectedValues)
.where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId)),
.from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)),
]);
if (participantRows.length === 0) {

View file

@ -214,9 +214,9 @@ export class NCAAFootballSimulator implements Simulator {
// 1. Load all participants for this sports season.
const participants = await db
.select({ id: schema.participants.id })
.from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId));
.select({ id: schema.seasonParticipants.id })
.from(schema.seasonParticipants)
.where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId));
if (participants.length < BRACKET_SIZE) {
throw new Error(
@ -228,12 +228,12 @@ export class NCAAFootballSimulator implements Simulator {
// 2. Load Elo/FPI ratings and optional futures odds in a single query.
const evRows = await db
.select({
participantId: schema.participantExpectedValues.participantId,
sourceElo: schema.participantExpectedValues.sourceElo,
sourceOdds: schema.participantExpectedValues.sourceOdds,
participantId: schema.seasonParticipantExpectedValues.participantId,
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds,
})
.from(schema.participantExpectedValues)
.where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId));
.from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
// Build Elo and raw odds maps in a single pass.
const eloFromDb = new Map<string, number>();

View file

@ -494,9 +494,9 @@ export class NCAAMSimulator implements Simulator {
// 6. Load participant names from DB; build net rating lookup by ID.
const participantRows = await db
.select({ id: schema.participants.id, name: schema.participants.name })
.from(schema.participants)
.where(inArray(schema.participants.id, participantIds));
.select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name })
.from(schema.seasonParticipants)
.where(inArray(schema.seasonParticipants.id, participantIds));
if (participantRows.length < participantIds.length) {
const foundIds = new Set(participantRows.map((r) => r.id));

View file

@ -457,9 +457,9 @@ export class NCAAWSimulator implements Simulator {
// 6. Load participant names from DB; build Barthag lookup by ID.
const participantRows = await db
.select({ id: schema.participants.id, name: schema.participants.name })
.from(schema.participants)
.where(inArray(schema.participants.id, participantIds));
.select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name })
.from(schema.seasonParticipants)
.where(inArray(schema.seasonParticipants.id, participantIds));
if (participantRows.length < participantIds.length) {
const foundIds = new Set(participantRows.map((r) => r.id));

View file

@ -296,8 +296,8 @@ export class NFLSimulator implements Simulator {
const db = database();
// Load participants
const participants = await db.query.participants.findMany({
where: eq(schema.participants.sportsSeasonId, sportsSeasonId),
const participants = await db.query.seasonParticipants.findMany({
where: eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId),
});
if (participants.length === 0) {
@ -307,12 +307,12 @@ export class NFLSimulator implements Simulator {
// Load EV rows (sourceElo + sourceOdds)
const evRows = await db
.select({
participantId: schema.participantExpectedValues.participantId,
sourceElo: schema.participantExpectedValues.sourceElo,
sourceOdds: schema.participantExpectedValues.sourceOdds,
participantId: schema.seasonParticipantExpectedValues.participantId,
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds,
})
.from(schema.participantExpectedValues)
.where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId));
.from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
// Build Elo map: prefer sourceElo, fall back to converting sourceOdds
const eloMap = new Map<string, number>();

View file

@ -278,18 +278,18 @@ export class NHLSimulator implements Simulator {
// 1. Load participants, standings, and sourceElo values in parallel.
const [participantRows, standings, evRows] = await Promise.all([
db
.select({ id: schema.participants.id, name: schema.participants.name })
.from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId)),
.select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name })
.from(schema.seasonParticipants)
.where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId)),
getRegularSeasonStandings(sportsSeasonId),
db
.select({
participantId: schema.participantExpectedValues.participantId,
sourceElo: schema.participantExpectedValues.sourceElo,
sourceOdds: schema.participantExpectedValues.sourceOdds,
participantId: schema.seasonParticipantExpectedValues.participantId,
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds,
})
.from(schema.participantExpectedValues)
.where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId)),
.from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)),
]);
if (participantRows.length === 0) {
@ -620,17 +620,17 @@ export class NHLSimulator implements Simulator {
// Load all participants and EV data in parallel.
const [participantRows, evRows] = await Promise.all([
db
.select({ id: schema.participants.id, name: schema.participants.name })
.from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId)),
.select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name })
.from(schema.seasonParticipants)
.where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId)),
db
.select({
participantId: schema.participantExpectedValues.participantId,
sourceOdds: schema.participantExpectedValues.sourceOdds,
sourceElo: schema.participantExpectedValues.sourceElo,
participantId: schema.seasonParticipantExpectedValues.participantId,
sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds,
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
})
.from(schema.participantExpectedValues)
.where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId)),
.from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)),
]);
const allParticipantIds = participantRows.map((r) => r.id);

View file

@ -251,11 +251,11 @@ export class SnookerSimulator implements Simulator {
// 3. Load Elo ratings from participantExpectedValues.
const evRows = await db
.select({
participantId: schema.participantExpectedValues.participantId,
sourceElo: schema.participantExpectedValues.sourceElo,
participantId: schema.seasonParticipantExpectedValues.participantId,
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
})
.from(schema.participantExpectedValues)
.where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId));
.from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
// Build Elo map; fall back to 1500 for any participant with no stored rating.
const eloMap = new Map<string, number>();
@ -453,9 +453,9 @@ export class SnookerSimulator implements Simulator {
db: ReturnType<typeof database>
): Promise<SimulationResult[]> {
const allParticipants = await db
.select({ id: schema.participants.id, name: schema.participants.name })
.from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId));
.select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name })
.from(schema.seasonParticipants)
.where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId));
if (allParticipants.length < 17) {
throw new Error(

View file

@ -262,9 +262,9 @@ export class TennisSimulator implements Simulator {
// 1. Load all participants for this sports season.
const allParticipants = await db
.select({ id: schema.participants.id })
.from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId));
.select({ id: schema.seasonParticipants.id })
.from(schema.seasonParticipants)
.where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId));
if (allParticipants.length < 128) {
throw new Error(
@ -306,7 +306,7 @@ export class TennisSimulator implements Simulator {
if (completedEventIds.length > 0) {
const actualResults = await db
.select({
participantId: schema.eventResults.participantId,
participantId: schema.eventResults.seasonParticipantId,
qualifyingPointsAwarded: schema.eventResults.qualifyingPointsAwarded,
})
.from(schema.eventResults)

View file

@ -147,11 +147,11 @@ export class UCLSimulator implements Simulator {
// 5. Load futures odds from participantExpectedValues.
const evRows = await db
.select({
participantId: schema.participantExpectedValues.participantId,
sourceOdds: schema.participantExpectedValues.sourceOdds,
participantId: schema.seasonParticipantExpectedValues.participantId,
sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds,
})
.from(schema.participantExpectedValues)
.where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId));
.from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
const evMap = new Map(evRows.map((r) => [r.participantId, r]));

View file

@ -159,18 +159,18 @@ export class WNBASimulator implements Simulator {
// 1. Load participants, standings, and futures odds in parallel.
const [participantRows, standings, evRows] = await Promise.all([
db
.select({ id: schema.participants.id, name: schema.participants.name })
.from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId)),
.select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name })
.from(schema.seasonParticipants)
.where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId)),
getRegularSeasonStandings(sportsSeasonId),
db
.select({
participantId: schema.participantExpectedValues.participantId,
sourceOdds: schema.participantExpectedValues.sourceOdds,
sourceElo: schema.participantExpectedValues.sourceElo,
participantId: schema.seasonParticipantExpectedValues.participantId,
sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds,
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
})
.from(schema.participantExpectedValues)
.where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId)),
.from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)),
]);
if (participantRows.length === 0) {

View file

@ -313,8 +313,8 @@ export class WorldCupSimulator implements Simulator {
const db = database();
// 1. Load all participants for this season
const participantRows = await db.query.participants.findMany({
where: eq(schema.participants.sportsSeasonId, sportsSeasonId),
const participantRows = await db.query.seasonParticipants.findMany({
where: eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId),
});
if (participantRows.length === 0) {
@ -327,12 +327,12 @@ export class WorldCupSimulator implements Simulator {
// 2. Load stored ratings (sourceElo from Elo page, sourceOdds from futures page)
const evRows = await db
.select({
participantId: schema.participantExpectedValues.participantId,
sourceOdds: schema.participantExpectedValues.sourceOdds,
sourceElo: schema.participantExpectedValues.sourceElo,
participantId: schema.seasonParticipantExpectedValues.participantId,
sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds,
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
})
.from(schema.participantExpectedValues)
.where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId));
.from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
const evMap = new Map(evRows.map((r) => [r.participantId, r]));
const participantSet = new Set(participantIds);

View file

@ -1,7 +1,7 @@
import { database } from "~/database/context";
import { eq } from "drizzle-orm";
import * as schema from "~/database/schema";
import { findParticipantsBySportsSeasonId, updateParticipant } from "~/models/participant";
import { findParticipantsBySportsSeasonId, updateParticipant } from "~/models/season-participant";
import { upsertRegularSeasonStandings } from "~/models/regular-season-standings";
import { upsertPendingStandingsMappings } from "~/models/pending-standings-mappings";
import { findMatchingTeamName } from "~/lib/normalize-team-name";

View file

@ -115,16 +115,16 @@ export async function exportSportsDataToJSON(): Promise<ExportData> {
with: { sport: true },
orderBy: (s, { desc, asc }) => [desc(s.year), asc(s.name)],
}),
db.query.participants.findMany({
db.query.seasonParticipants.findMany({
with: { sportsSeason: { with: { sport: true } } },
orderBy: (p, { asc }) => [asc(p.name)],
}),
db.query.participantExpectedValues.findMany({
db.query.seasonParticipantExpectedValues.findMany({
with: {
participant: { with: { sportsSeason: { with: { sport: true } } } },
},
}),
db.query.participantResults.findMany({
db.query.seasonParticipantResults.findMany({
with: {
participant: { with: { sportsSeason: { with: { sport: true } } } },
},
@ -238,7 +238,7 @@ export async function importSportsDataFromJSON(
// automatically removed when participants is deleted.
await tx.delete(schema.seasonTemplateSports);
await tx.delete(schema.seasonTemplates);
await tx.delete(schema.participants); // cascades to EVs + results
await tx.delete(schema.seasonParticipants); // cascades to EVs + results
await tx.delete(schema.seasonSports);
await tx.delete(schema.sportsSeasons);
await tx.delete(schema.sports);
@ -358,28 +358,28 @@ export async function importSportsDataFromJSON(
continue;
}
const existing = await tx.query.participants.findFirst({
const existing = await tx.query.seasonParticipants.findFirst({
where: and(
eq(schema.participants.sportsSeasonId, sportsSeasonId),
eq(schema.participants.name, participant.name)
eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId),
eq(schema.seasonParticipants.name, participant.name)
),
});
if (existing) {
// Update all mutable fields (fixes: merge mode was silently skipping)
await tx
.update(schema.participants)
.update(schema.seasonParticipants)
.set({
shortName: participant.shortName,
externalId: participant.externalId,
expectedValue: String(participant.expectedValue ?? 0),
})
.where(eq(schema.participants.id, existing.id));
.where(eq(schema.seasonParticipants.id, existing.id));
participantIdMap.set(`${sportsSeasonId}:${participant.name}`, existing.id);
updated++;
} else {
const [created_] = await tx
.insert(schema.participants)
.insert(schema.seasonParticipants)
.values({
sportsSeasonId,
name: participant.name,
@ -413,10 +413,10 @@ export async function importSportsDataFromJSON(
continue;
}
const existingEV = await tx.query.participantExpectedValues.findFirst({
const existingEV = await tx.query.seasonParticipantExpectedValues.findFirst({
where: and(
eq(schema.participantExpectedValues.participantId, participantId),
eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId)
eq(schema.seasonParticipantExpectedValues.participantId, participantId),
eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)
),
});
@ -446,25 +446,25 @@ export async function importSportsDataFromJSON(
if (existingEV) {
if (mode === "merge") {
await tx
.update(schema.participantExpectedValues)
.update(schema.seasonParticipantExpectedValues)
.set({ ...evValues, updatedAt: new Date() })
.where(eq(schema.participantExpectedValues.id, existingEV.id));
.where(eq(schema.seasonParticipantExpectedValues.id, existingEV.id));
await tx
.update(schema.participants)
.update(schema.seasonParticipants)
.set({ expectedValue: ev.expectedValue })
.where(eq(schema.participants.id, participantId));
.where(eq(schema.seasonParticipants.id, participantId));
updated++;
}
} else {
await tx.insert(schema.participantExpectedValues).values({
await tx.insert(schema.seasonParticipantExpectedValues).values({
participantId,
sportsSeasonId,
...evValues,
});
await tx
.update(schema.participants)
.update(schema.seasonParticipants)
.set({ expectedValue: ev.expectedValue })
.where(eq(schema.participants.id, participantId));
.where(eq(schema.seasonParticipants.id, participantId));
created++;
}
}
@ -489,10 +489,10 @@ export async function importSportsDataFromJSON(
continue;
}
const existingResult = await tx.query.participantResults.findFirst({
const existingResult = await tx.query.seasonParticipantResults.findFirst({
where: and(
eq(schema.participantResults.participantId, participantId),
eq(schema.participantResults.sportsSeasonId, sportsSeasonId)
eq(schema.seasonParticipantResults.participantId, participantId),
eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId)
),
});
@ -505,13 +505,13 @@ export async function importSportsDataFromJSON(
if (existingResult) {
if (mode === "merge") {
await tx
.update(schema.participantResults)
.update(schema.seasonParticipantResults)
.set({ ...resultValues, updatedAt: new Date() })
.where(eq(schema.participantResults.id, existingResult.id));
.where(eq(schema.seasonParticipantResults.id, existingResult.id));
updated++;
}
} else {
await tx.insert(schema.participantResults).values({
await tx.insert(schema.seasonParticipantResults).values({
participantId,
sportsSeasonId,
...resultValues,

View file

@ -150,6 +150,12 @@ export const simulatorTypeEnum = pgEnum("simulator_type", [
"llws_bracket",
]);
export const tournamentStatusEnum = pgEnum("tournament_status", [
"scheduled",
"in_progress",
"completed",
]);
export const playoffMatchGameStatusEnum = pgEnum("playoff_match_game_status", [
"scheduled",
"complete",
@ -249,7 +255,7 @@ export const draftPicks = pgTable("draft_picks", {
.references(() => teams.id, { onDelete: "cascade" }),
participantId: uuid("participant_id")
.notNull()
.references(() => participants.id, { onDelete: "cascade" }),
.references(() => seasonParticipants.id, { onDelete: "cascade" }),
pickNumber: integer("pick_number").notNull(),
round: integer("round").notNull(),
pickInRound: integer("pick_in_round").notNull(),
@ -272,7 +278,7 @@ export const draftQueue = pgTable("draft_queue", {
.references(() => teams.id, { onDelete: "cascade" }),
participantId: uuid("participant_id")
.notNull()
.references(() => participants.id, { onDelete: "cascade" }),
.references(() => seasonParticipants.id, { onDelete: "cascade" }),
queuePosition: integer("queue_position").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
@ -288,7 +294,7 @@ export const watchlist = pgTable("watchlist", {
.references(() => teams.id, { onDelete: "cascade" }),
participantId: uuid("participant_id")
.notNull()
.references(() => participants.id, { onDelete: "cascade" }),
.references(() => seasonParticipants.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at").defaultNow().notNull(),
}, (t) => ({
uniqueWatch: uniqueIndex("watchlist_season_team_participant_unique").on(t.seasonId, t.teamId, t.participantId),
@ -373,11 +379,14 @@ export const sportsSeasons = pgTable("sports_seasons", {
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const participants = pgTable("participants", {
export const seasonParticipants = pgTable("season_participants", {
id: uuid("id").primaryKey().defaultRandom(),
sportsSeasonId: uuid("sports_season_id")
.notNull()
.references(() => sportsSeasons.id, { onDelete: "cascade" }),
participantId: uuid("participant_id").references(() => participants.id, {
onDelete: "restrict",
}),
name: varchar("name", { length: 255 }).notNull(),
shortName: varchar("short_name", { length: 100 }),
externalId: varchar("external_id", { length: 255 }),
@ -389,6 +398,96 @@ export const participants = pgTable("participants", {
uniqueIndex("participants_sports_season_name_unique").on(table.sportsSeasonId, table.name),
]);
export const participants = pgTable(
"participants",
{
id: uuid("id").primaryKey().defaultRandom(),
sportId: uuid("sport_id")
.notNull()
.references(() => sports.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
externalKey: varchar("external_key", { length: 255 }),
metadata: jsonb("metadata"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
},
(t) => ({
uniqueSportName: uniqueIndex("participants_sport_name_unique").on(
t.sportId,
t.name,
),
}),
);
export const tournaments = pgTable(
"tournaments",
{
id: uuid("id").primaryKey().defaultRandom(),
sportId: uuid("sport_id")
.notNull()
.references(() => sports.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
year: integer("year").notNull(),
startsAt: timestamp("starts_at"),
endsAt: timestamp("ends_at"),
surface: varchar("surface", { length: 50 }), // tennis only: hard | clay | grass
location: varchar("location", { length: 255 }),
status: tournamentStatusEnum("status").notNull().default("scheduled"),
externalKey: varchar("external_key", { length: 255 }),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
},
(t) => ({
uniqueSportNameYear: uniqueIndex("tournaments_sport_name_year_unique").on(
t.sportId,
t.name,
t.year,
),
}),
);
export const tournamentResults = pgTable(
"tournament_results",
{
id: uuid("id").primaryKey().defaultRandom(),
tournamentId: uuid("tournament_id")
.notNull()
.references(() => tournaments.id, { onDelete: "cascade" }),
participantId: uuid("participant_id")
.notNull()
.references(() => participants.id, { onDelete: "cascade" }),
placement: integer("placement"),
rawScore: decimal("raw_score", { precision: 10, scale: 2 }),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
},
(t) => ({
uniqueTournamentParticipant: uniqueIndex(
"tournament_results_tournament_participant_unique",
).on(t.tournamentId, t.participantId),
}),
);
export const participantSurfaceElos = pgTable(
"participant_surface_elos",
{
id: uuid("id").primaryKey().defaultRandom(),
participantId: uuid("participant_id")
.notNull()
.references(() => participants.id, { onDelete: "cascade" }),
worldRanking: integer("world_ranking"),
eloHard: integer("elo_hard"),
eloClay: integer("elo_clay"),
eloGrass: integer("elo_grass"),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
},
(t) => ({
uniqueParticipant: uniqueIndex("participant_surface_elos_participant_unique").on(
t.participantId,
),
}),
);
export const seasonTemplateSports = pgTable("season_template_sports", {
id: uuid("id").primaryKey().defaultRandom(),
templateId: uuid("template_id")
@ -411,11 +510,11 @@ export const seasonSports = pgTable("season_sports", {
createdAt: timestamp("created_at").defaultNow().notNull(),
});
export const participantResults = pgTable("participant_results", {
export const seasonParticipantResults = pgTable("season_participant_results", {
id: uuid("id").primaryKey().defaultRandom(),
participantId: uuid("participant_id")
.notNull()
.references(() => participants.id, { onDelete: "cascade" }),
.references(() => seasonParticipants.id, { onDelete: "cascade" }),
sportsSeasonId: uuid("sports_season_id")
.notNull()
.references(() => sportsSeasons.id, { onDelete: "cascade" }),
@ -435,6 +534,9 @@ export const scoringEvents = pgTable("scoring_events", {
sportsSeasonId: uuid("sports_season_id")
.notNull()
.references(() => sportsSeasons.id, { onDelete: "cascade" }),
tournamentId: uuid("tournament_id").references(() => tournaments.id, {
onDelete: "set null",
}),
name: varchar("name", { length: 255 }).notNull(), // "2024 Masters", "Super Bowl LIX", etc.
eventDate: date("event_date"),
eventStartsAt: timestamp("event_starts_at", { withTimezone: true }),
@ -460,9 +562,9 @@ export const eventResults = pgTable("event_results", {
scoringEventId: uuid("scoring_event_id")
.notNull()
.references(() => scoringEvents.id, { onDelete: "cascade" }),
participantId: uuid("participant_id")
seasonParticipantId: uuid("season_participant_id")
.notNull()
.references(() => participants.id, { onDelete: "cascade" }),
.references(() => seasonParticipants.id, { onDelete: "cascade" }),
// Placement in this specific event
placement: integer("placement"),
// For qualifying events: QP awarded in this event
@ -483,10 +585,10 @@ export const playoffMatches = pgTable("playoff_matches", {
.references(() => scoringEvents.id, { onDelete: "cascade" }),
round: varchar("round", { length: 50 }).notNull(), // "Quarterfinals", "Semifinals", "Finals"
matchNumber: integer("match_number").notNull(), // 1, 2, 3, 4 (for QF); 1, 2 (for SF); 1 (for F)
participant1Id: uuid("participant1_id").references(() => participants.id, { onDelete: "set null" }),
participant2Id: uuid("participant2_id").references(() => participants.id, { onDelete: "set null" }),
winnerId: uuid("winner_id").references(() => participants.id, { onDelete: "set null" }),
loserId: uuid("loser_id").references(() => participants.id, { onDelete: "set null" }),
participant1Id: uuid("participant1_id").references(() => seasonParticipants.id, { onDelete: "set null" }),
participant2Id: uuid("participant2_id").references(() => seasonParticipants.id, { onDelete: "set null" }),
winnerId: uuid("winner_id").references(() => seasonParticipants.id, { onDelete: "set null" }),
loserId: uuid("loser_id").references(() => seasonParticipants.id, { onDelete: "set null" }),
isComplete: boolean("is_complete").notNull().default(false),
// Optional detailed data
participant1Score: decimal("participant1_score", { precision: 10, scale: 2 }),
@ -510,7 +612,7 @@ export const playoffMatchGames = pgTable("playoff_match_games", {
status: playoffMatchGameStatusEnum("status").notNull().default("scheduled"),
participant1Score: decimal("participant1_score", { precision: 10, scale: 2 }),
participant2Score: decimal("participant2_score", { precision: 10, scale: 2 }),
winnerId: uuid("winner_id").references(() => participants.id, { onDelete: "set null" }),
winnerId: uuid("winner_id").references(() => seasonParticipants.id, { onDelete: "set null" }),
notes: text("notes"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
@ -524,7 +626,7 @@ export const playoffMatchOdds = pgTable("playoff_match_odds", {
.references(() => playoffMatches.id, { onDelete: "cascade" }),
participantId: uuid("participant_id")
.notNull()
.references(() => participants.id, { onDelete: "cascade" }),
.references(() => seasonParticipants.id, { onDelete: "cascade" }),
moneylineOdds: integer("moneyline_odds"), // American format e.g. -110, +150
impliedProbability: decimal("implied_probability", { precision: 6, scale: 4 }),
oddsSource: varchar("odds_source", { length: 100 }),
@ -534,11 +636,11 @@ export const playoffMatchOdds = pgTable("playoff_match_odds", {
});
// Aggregated participant qualifying points (for qualifying_points sports)
export const participantQualifyingTotals = pgTable("participant_qualifying_totals", {
export const seasonParticipantQualifyingTotals = pgTable("season_participant_qualifying_totals", {
id: uuid("id").primaryKey().defaultRandom(),
participantId: uuid("participant_id")
.notNull()
.references(() => participants.id, { onDelete: "cascade" }),
.references(() => seasonParticipants.id, { onDelete: "cascade" }),
sportsSeasonId: uuid("sports_season_id")
.notNull()
.references(() => sportsSeasons.id, { onDelete: "cascade" }),
@ -639,11 +741,11 @@ export const teamStandingsSnapshots = pgTable("team_standings_snapshots", {
}));
// Expected value tracking (sports-season-specific)
export const participantExpectedValues = pgTable("participant_expected_values", {
export const seasonParticipantExpectedValues = pgTable("season_participant_expected_values", {
id: uuid("id").primaryKey().defaultRandom(),
participantId: uuid("participant_id")
.notNull()
.references(() => participants.id, { onDelete: "cascade" }),
.references(() => seasonParticipants.id, { onDelete: "cascade" }),
sportsSeasonId: uuid("sports_season_id")
.notNull()
.references(() => sportsSeasons.id, { onDelete: "cascade" }),
@ -688,7 +790,7 @@ export const tournamentGroupMembers = pgTable("tournament_group_members", {
.references(() => tournamentGroups.id, { onDelete: "cascade" }),
participantId: uuid("participant_id")
.notNull()
.references(() => participants.id, { onDelete: "cascade" }),
.references(() => seasonParticipants.id, { onDelete: "cascade" }),
eliminated: boolean("eliminated").notNull().default(false),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
@ -701,10 +803,10 @@ export const groupStageMatches = pgTable("group_stage_matches", {
.references(() => tournamentGroups.id, { onDelete: "cascade" }),
participant1Id: uuid("participant1_id")
.notNull()
.references(() => participants.id),
.references(() => seasonParticipants.id),
participant2Id: uuid("participant2_id")
.notNull()
.references(() => participants.id),
.references(() => seasonParticipants.id),
participant1Score: integer("participant1_score"),
participant2Score: integer("participant2_score"),
isComplete: boolean("is_complete").notNull().default(false),
@ -723,7 +825,7 @@ export const participantSeasonResults = pgTable("participant_season_results", {
id: uuid("id").primaryKey().defaultRandom(),
participantId: uuid("participant_id")
.notNull()
.references(() => participants.id, { onDelete: "cascade" }),
.references(() => seasonParticipants.id, { onDelete: "cascade" }),
sportsSeasonId: uuid("sports_season_id")
.notNull()
.references(() => sportsSeasons.id, { onDelete: "cascade" }),
@ -737,7 +839,7 @@ export const participantEvSnapshots = pgTable("participant_ev_snapshots", {
id: uuid("id").primaryKey().defaultRandom(),
participantId: uuid("participant_id")
.notNull()
.references(() => participants.id, { onDelete: "cascade" }),
.references(() => seasonParticipants.id, { onDelete: "cascade" }),
sportsSeasonId: uuid("sports_season_id")
.notNull()
.references(() => sportsSeasons.id, { onDelete: "cascade" }),
@ -783,6 +885,8 @@ export const teamEvSnapshots = pgTable("team_ev_snapshots", {
// Relations
export const sportsRelations = relations(sports, ({ many }) => ({
sportsSeasons: many(sportsSeasons),
tournaments: many(tournaments),
participants: many(participants),
}));
export const sportsSeasonsRelations = relations(sportsSeasons, ({ one, many }) => ({
@ -790,23 +894,70 @@ export const sportsSeasonsRelations = relations(sportsSeasons, ({ one, many }) =
fields: [sportsSeasons.sportId],
references: [sports.id],
}),
participants: many(participants),
participants: many(seasonParticipants),
seasonTemplateSports: many(seasonTemplateSports),
seasonSports: many(seasonSports),
participantResults: many(participantResults),
participantResults: many(seasonParticipantResults),
regularSeasonStandings: many(regularSeasonStandings),
pendingStandingsMappings: many(pendingStandingsMappings),
}));
export const participantsRelations = relations(participants, ({ one, many }) => ({
export const seasonParticipantsRelations = relations(seasonParticipants, ({ one, many }) => ({
sportsSeason: one(sportsSeasons, {
fields: [participants.sportsSeasonId],
fields: [seasonParticipants.sportsSeasonId],
references: [sportsSeasons.id],
}),
results: many(participantResults),
canonicalParticipant: one(participants, {
fields: [seasonParticipants.participantId],
references: [participants.id],
}),
results: many(seasonParticipantResults),
regularSeasonStandings: many(regularSeasonStandings),
}));
export const participantsRelations = relations(participants, ({ one, many }) => ({
sport: one(sports, {
fields: [participants.sportId],
references: [sports.id],
}),
seasonParticipants: many(seasonParticipants),
tournamentResults: many(tournamentResults),
surfaceElo: one(participantSurfaceElos, {
fields: [participants.id],
references: [participantSurfaceElos.participantId],
}),
}));
export const tournamentsRelations = relations(tournaments, ({ one, many }) => ({
sport: one(sports, {
fields: [tournaments.sportId],
references: [sports.id],
}),
scoringEvents: many(scoringEvents),
tournamentResults: many(tournamentResults),
}));
export const tournamentResultsRelations = relations(tournamentResults, ({ one }) => ({
tournament: one(tournaments, {
fields: [tournamentResults.tournamentId],
references: [tournaments.id],
}),
participant: one(participants, {
fields: [tournamentResults.participantId],
references: [participants.id],
}),
}));
export const participantSurfaceElosRelations = relations(
participantSurfaceElos,
({ one }) => ({
participant: one(participants, {
fields: [participantSurfaceElos.participantId],
references: [participants.id],
}),
}),
);
export const seasonTemplatesRelations = relations(seasonTemplates, ({ many }) => ({
seasonTemplateSports: many(seasonTemplateSports),
seasons: many(seasons),
@ -847,13 +998,13 @@ export const seasonSportsRelations = relations(seasonSports, ({ one }) => ({
}),
}));
export const participantResultsRelations = relations(participantResults, ({ one }) => ({
participant: one(participants, {
fields: [participantResults.participantId],
references: [participants.id],
export const seasonParticipantResultsRelations = relations(seasonParticipantResults, ({ one }) => ({
participant: one(seasonParticipants, {
fields: [seasonParticipantResults.participantId],
references: [seasonParticipants.id],
}),
sportsSeason: one(sportsSeasons, {
fields: [participantResults.sportsSeasonId],
fields: [seasonParticipantResults.sportsSeasonId],
references: [sportsSeasons.id],
}),
}));
@ -889,9 +1040,9 @@ export const draftPicksRelations = relations(draftPicks, ({ one }) => ({
fields: [draftPicks.teamId],
references: [teams.id],
}),
participant: one(participants, {
participant: one(seasonParticipants, {
fields: [draftPicks.participantId],
references: [participants.id],
references: [seasonParticipants.id],
}),
}));
@ -904,9 +1055,9 @@ export const draftQueueRelations = relations(draftQueue, ({ one }) => ({
fields: [draftQueue.teamId],
references: [teams.id],
}),
participant: one(participants, {
participant: one(seasonParticipants, {
fields: [draftQueue.participantId],
references: [participants.id],
references: [seasonParticipants.id],
}),
}));
@ -919,9 +1070,9 @@ export const watchlistRelations = relations(watchlist, ({ one }) => ({
fields: [watchlist.teamId],
references: [teams.id],
}),
participant: one(participants, {
participant: one(seasonParticipants, {
fields: [watchlist.participantId],
references: [participants.id],
references: [seasonParticipants.id],
}),
}));
@ -943,6 +1094,10 @@ export const scoringEventsRelations = relations(scoringEvents, ({ one, many }) =
fields: [scoringEvents.sportsSeasonId],
references: [sportsSeasons.id],
}),
tournament: one(tournaments, {
fields: [scoringEvents.tournamentId],
references: [tournaments.id],
}),
eventResults: many(eventResults),
playoffMatches: many(playoffMatches),
tournamentGroups: many(tournamentGroups),
@ -954,9 +1109,9 @@ export const eventResultsRelations = relations(eventResults, ({ one }) => ({
fields: [eventResults.scoringEventId],
references: [scoringEvents.id],
}),
participant: one(participants, {
fields: [eventResults.participantId],
references: [participants.id],
seasonParticipant: one(seasonParticipants, {
fields: [eventResults.seasonParticipantId],
references: [seasonParticipants.id],
}),
}));
@ -965,21 +1120,21 @@ export const playoffMatchesRelations = relations(playoffMatches, ({ one, many })
fields: [playoffMatches.scoringEventId],
references: [scoringEvents.id],
}),
participant1: one(participants, {
participant1: one(seasonParticipants, {
fields: [playoffMatches.participant1Id],
references: [participants.id],
references: [seasonParticipants.id],
}),
participant2: one(participants, {
participant2: one(seasonParticipants, {
fields: [playoffMatches.participant2Id],
references: [participants.id],
references: [seasonParticipants.id],
}),
winner: one(participants, {
winner: one(seasonParticipants, {
fields: [playoffMatches.winnerId],
references: [participants.id],
references: [seasonParticipants.id],
}),
loser: one(participants, {
loser: one(seasonParticipants, {
fields: [playoffMatches.loserId],
references: [participants.id],
references: [seasonParticipants.id],
}),
games: many(playoffMatchGames),
odds: many(playoffMatchOdds),
@ -990,9 +1145,9 @@ export const playoffMatchGamesRelations = relations(playoffMatchGames, ({ one })
fields: [playoffMatchGames.playoffMatchId],
references: [playoffMatches.id],
}),
winner: one(participants, {
winner: one(seasonParticipants, {
fields: [playoffMatchGames.winnerId],
references: [participants.id],
references: [seasonParticipants.id],
}),
}));
@ -1001,19 +1156,19 @@ export const playoffMatchOddsRelations = relations(playoffMatchOdds, ({ one }) =
fields: [playoffMatchOdds.playoffMatchId],
references: [playoffMatches.id],
}),
participant: one(participants, {
participant: one(seasonParticipants, {
fields: [playoffMatchOdds.participantId],
references: [participants.id],
references: [seasonParticipants.id],
}),
}));
export const participantQualifyingTotalsRelations = relations(participantQualifyingTotals, ({ one }) => ({
participant: one(participants, {
fields: [participantQualifyingTotals.participantId],
references: [participants.id],
export const seasonParticipantQualifyingTotalsRelations = relations(seasonParticipantQualifyingTotals, ({ one }) => ({
participant: one(seasonParticipants, {
fields: [seasonParticipantQualifyingTotals.participantId],
references: [seasonParticipants.id],
}),
sportsSeason: one(sportsSeasons, {
fields: [participantQualifyingTotals.sportsSeasonId],
fields: [seasonParticipantQualifyingTotals.sportsSeasonId],
references: [sportsSeasons.id],
}),
}));
@ -1058,21 +1213,21 @@ export const teamStandingsSnapshotsRelations = relations(teamStandingsSnapshots,
}),
}));
export const participantExpectedValuesRelations = relations(participantExpectedValues, ({ one }) => ({
participant: one(participants, {
fields: [participantExpectedValues.participantId],
references: [participants.id],
export const seasonParticipantExpectedValuesRelations = relations(seasonParticipantExpectedValues, ({ one }) => ({
participant: one(seasonParticipants, {
fields: [seasonParticipantExpectedValues.participantId],
references: [seasonParticipants.id],
}),
sportsSeason: one(sportsSeasons, {
fields: [participantExpectedValues.sportsSeasonId],
fields: [seasonParticipantExpectedValues.sportsSeasonId],
references: [sportsSeasons.id],
}),
}));
export const participantSeasonResultsRelations = relations(participantSeasonResults, ({ one }) => ({
participant: one(participants, {
participant: one(seasonParticipants, {
fields: [participantSeasonResults.participantId],
references: [participants.id],
references: [seasonParticipants.id],
}),
sportsSeason: one(sportsSeasons, {
fields: [participantSeasonResults.sportsSeasonId],
@ -1095,9 +1250,9 @@ export const tournamentGroupMembersRelations = relations(tournamentGroupMembers,
fields: [tournamentGroupMembers.tournamentGroupId],
references: [tournamentGroups.id],
}),
participant: one(participants, {
participant: one(seasonParticipants, {
fields: [tournamentGroupMembers.participantId],
references: [participants.id],
references: [seasonParticipants.id],
}),
}));
@ -1106,22 +1261,22 @@ export const groupStageMatchesRelations = relations(groupStageMatches, ({ one })
fields: [groupStageMatches.tournamentGroupId],
references: [tournamentGroups.id],
}),
participant1: one(participants, {
participant1: one(seasonParticipants, {
fields: [groupStageMatches.participant1Id],
references: [participants.id],
references: [seasonParticipants.id],
relationName: "groupMatchParticipant1",
}),
participant2: one(participants, {
participant2: one(seasonParticipants, {
fields: [groupStageMatches.participant2Id],
references: [participants.id],
references: [seasonParticipants.id],
relationName: "groupMatchParticipant2",
}),
}));
export const participantEvSnapshotsRelations = relations(participantEvSnapshots, ({ one }) => ({
participant: one(participants, {
participant: one(seasonParticipants, {
fields: [participantEvSnapshots.participantId],
references: [participants.id],
references: [seasonParticipants.id],
}),
sportsSeason: one(sportsSeasons, {
fields: [participantEvSnapshots.sportsSeasonId],
@ -1146,7 +1301,7 @@ export const regularSeasonStandings = pgTable("regular_season_standings", {
id: uuid("id").primaryKey().defaultRandom(),
participantId: uuid("participant_id")
.notNull()
.references(() => participants.id, { onDelete: "cascade" }),
.references(() => seasonParticipants.id, { onDelete: "cascade" }),
sportsSeasonId: uuid("sports_season_id")
.notNull()
.references(() => sportsSeasons.id, { onDelete: "cascade" }),
@ -1177,9 +1332,9 @@ export const regularSeasonStandings = pgTable("regular_season_standings", {
}));
export const regularSeasonStandingsRelations = relations(regularSeasonStandings, ({ one }) => ({
participant: one(participants, {
participant: one(seasonParticipants, {
fields: [regularSeasonStandings.participantId],
references: [participants.id],
references: [seasonParticipants.id],
}),
sportsSeason: one(sportsSeasons, {
fields: [regularSeasonStandings.sportsSeasonId],
@ -1216,11 +1371,11 @@ export const pendingStandingsMappingsRelations = relations(pendingStandingsMappi
// Stores surface-specific Elo ratings for tennis (and future surface-based sports).
// One row per (participantId, sportsSeasonId); nullable per surface.
export const participantSurfaceElos = pgTable("participant_surface_elos", {
export const seasonParticipantSurfaceElos = pgTable("season_participant_surface_elos", {
id: uuid("id").primaryKey().defaultRandom(),
participantId: uuid("participant_id")
.notNull()
.references(() => participants.id, { onDelete: "cascade" }),
.references(() => seasonParticipants.id, { onDelete: "cascade" }),
sportsSeasonId: uuid("sports_season_id")
.notNull()
.references(() => sportsSeasons.id, { onDelete: "cascade" }),
@ -1234,13 +1389,13 @@ export const participantSurfaceElos = pgTable("participant_surface_elos", {
.on(t.participantId, t.sportsSeasonId),
}));
export const participantSurfaceElosRelations = relations(participantSurfaceElos, ({ one }) => ({
participant: one(participants, {
fields: [participantSurfaceElos.participantId],
references: [participants.id],
export const seasonParticipantSurfaceElosRelations = relations(seasonParticipantSurfaceElos, ({ one }) => ({
participant: one(seasonParticipants, {
fields: [seasonParticipantSurfaceElos.participantId],
references: [seasonParticipants.id],
}),
sportsSeason: one(sportsSeasons, {
fields: [participantSurfaceElos.sportsSeasonId],
fields: [seasonParticipantSurfaceElos.sportsSeasonId],
references: [sportsSeasons.id],
}),
}));
@ -1255,7 +1410,7 @@ export const participantGolfSkills = pgTable("participant_golf_skills", {
id: uuid("id").primaryKey().defaultRandom(),
participantId: uuid("participant_id")
.notNull()
.references(() => participants.id, { onDelete: "cascade" }),
.references(() => seasonParticipants.id, { onDelete: "cascade" }),
sportsSeasonId: uuid("sports_season_id")
.notNull()
.references(() => sportsSeasons.id, { onDelete: "cascade" }),
@ -1275,9 +1430,9 @@ export const participantGolfSkills = pgTable("participant_golf_skills", {
}));
export const participantGolfSkillsRelations = relations(participantGolfSkills, ({ one }) => ({
participant: one(participants, {
participant: one(seasonParticipants, {
fields: [participantGolfSkills.participantId],
references: [participants.id],
references: [seasonParticipants.id],
}),
sportsSeason: one(sportsSeasons, {
fields: [participantGolfSkills.sportsSeasonId],
@ -1302,7 +1457,7 @@ export const cs2MajorStageResults = pgTable("cs2_major_stage_results", {
.references(() => scoringEvents.id, { onDelete: "cascade" }),
participantId: uuid("participant_id")
.notNull()
.references(() => participants.id, { onDelete: "cascade" }),
.references(() => seasonParticipants.id, { onDelete: "cascade" }),
stageEntry: integer("stage_entry").notNull(),
stageEliminated: integer("stage_eliminated"),
stageEliminatedWins: integer("stage_eliminated_wins"),
@ -1319,9 +1474,9 @@ export const cs2MajorStageResultsRelations = relations(cs2MajorStageResults, ({
fields: [cs2MajorStageResults.scoringEventId],
references: [scoringEvents.id],
}),
participant: one(participants, {
participant: one(seasonParticipants, {
fields: [cs2MajorStageResults.participantId],
references: [participants.id],
references: [seasonParticipants.id],
}),
}));

View file

@ -0,0 +1,221 @@
ALTER TABLE "participant_expected_values" RENAME TO "season_participant_expected_values";--> statement-breakpoint
ALTER TABLE "participant_qualifying_totals" RENAME TO "season_participant_qualifying_totals";--> statement-breakpoint
ALTER TABLE "participant_results" RENAME TO "season_participant_results";--> statement-breakpoint
ALTER TABLE "participant_surface_elos" RENAME TO "season_participant_surface_elos";--> statement-breakpoint
ALTER TABLE "participants" RENAME TO "season_participants";--> statement-breakpoint
ALTER TABLE "event_results" RENAME COLUMN "participant_id" TO "season_participant_id";--> statement-breakpoint
ALTER TABLE "cs2_major_stage_results" DROP CONSTRAINT IF EXISTS "cs2_major_stage_results_participant_id_participants_id_fk";
--> statement-breakpoint
ALTER TABLE "draft_picks" DROP CONSTRAINT IF EXISTS "draft_picks_participant_id_participants_id_fk";
--> statement-breakpoint
ALTER TABLE "draft_queue" DROP CONSTRAINT IF EXISTS "draft_queue_participant_id_participants_id_fk";
--> statement-breakpoint
ALTER TABLE "event_results" DROP CONSTRAINT IF EXISTS "event_results_participant_id_participants_id_fk";
--> statement-breakpoint
ALTER TABLE "group_stage_matches" DROP CONSTRAINT IF EXISTS "group_stage_matches_participant1_id_participants_id_fk";
--> statement-breakpoint
ALTER TABLE "group_stage_matches" DROP CONSTRAINT IF EXISTS "group_stage_matches_participant2_id_participants_id_fk";
--> statement-breakpoint
ALTER TABLE "participant_ev_snapshots" DROP CONSTRAINT IF EXISTS "participant_ev_snapshots_participant_id_participants_id_fk";
--> statement-breakpoint
ALTER TABLE "season_participant_expected_values" DROP CONSTRAINT IF EXISTS "participant_expected_values_participant_id_participants_id_fk";
--> statement-breakpoint
ALTER TABLE "season_participant_expected_values" DROP CONSTRAINT IF EXISTS "participant_expected_values_sports_season_id_sports_seasons_id_fk";
--> statement-breakpoint
ALTER TABLE "participant_golf_skills" DROP CONSTRAINT IF EXISTS "participant_golf_skills_participant_id_participants_id_fk";
--> statement-breakpoint
ALTER TABLE "season_participant_qualifying_totals" DROP CONSTRAINT IF EXISTS "participant_qualifying_totals_participant_id_participants_id_fk";
--> statement-breakpoint
ALTER TABLE "season_participant_qualifying_totals" DROP CONSTRAINT IF EXISTS "participant_qualifying_totals_sports_season_id_sports_seasons_id_fk";
--> statement-breakpoint
ALTER TABLE "season_participant_results" DROP CONSTRAINT IF EXISTS "participant_results_participant_id_participants_id_fk";
--> statement-breakpoint
ALTER TABLE "season_participant_results" DROP CONSTRAINT IF EXISTS "participant_results_sports_season_id_sports_seasons_id_fk";
--> statement-breakpoint
ALTER TABLE "participant_season_results" DROP CONSTRAINT IF EXISTS "participant_season_results_participant_id_participants_id_fk";
--> statement-breakpoint
ALTER TABLE "season_participant_surface_elos" DROP CONSTRAINT IF EXISTS "participant_surface_elos_participant_id_participants_id_fk";
--> statement-breakpoint
ALTER TABLE "season_participant_surface_elos" DROP CONSTRAINT IF EXISTS "participant_surface_elos_sports_season_id_sports_seasons_id_fk";
--> statement-breakpoint
ALTER TABLE "season_participants" DROP CONSTRAINT IF EXISTS "participants_sports_season_id_sports_seasons_id_fk";
--> statement-breakpoint
ALTER TABLE "playoff_match_games" DROP CONSTRAINT IF EXISTS "playoff_match_games_winner_id_participants_id_fk";
--> statement-breakpoint
ALTER TABLE "playoff_match_odds" DROP CONSTRAINT IF EXISTS "playoff_match_odds_participant_id_participants_id_fk";
--> statement-breakpoint
ALTER TABLE "playoff_matches" DROP CONSTRAINT IF EXISTS "playoff_matches_participant1_id_participants_id_fk";
--> statement-breakpoint
ALTER TABLE "playoff_matches" DROP CONSTRAINT IF EXISTS "playoff_matches_participant2_id_participants_id_fk";
--> statement-breakpoint
ALTER TABLE "playoff_matches" DROP CONSTRAINT IF EXISTS "playoff_matches_winner_id_participants_id_fk";
--> statement-breakpoint
ALTER TABLE "playoff_matches" DROP CONSTRAINT IF EXISTS "playoff_matches_loser_id_participants_id_fk";
--> statement-breakpoint
ALTER TABLE "regular_season_standings" DROP CONSTRAINT IF EXISTS "regular_season_standings_participant_id_participants_id_fk";
--> statement-breakpoint
ALTER TABLE "tournament_group_members" DROP CONSTRAINT IF EXISTS "tournament_group_members_participant_id_participants_id_fk";
--> statement-breakpoint
ALTER TABLE "watchlist" DROP CONSTRAINT IF EXISTS "watchlist_participant_id_participants_id_fk";
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "cs2_major_stage_results" ADD CONSTRAINT "cs2_major_stage_results_participant_id_season_participants_id_fk" FOREIGN KEY ("participant_id") REFERENCES "public"."season_participants"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "draft_picks" ADD CONSTRAINT "draft_picks_participant_id_season_participants_id_fk" FOREIGN KEY ("participant_id") REFERENCES "public"."season_participants"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "draft_queue" ADD CONSTRAINT "draft_queue_participant_id_season_participants_id_fk" FOREIGN KEY ("participant_id") REFERENCES "public"."season_participants"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "event_results" ADD CONSTRAINT "event_results_season_participant_id_season_participants_id_fk" FOREIGN KEY ("season_participant_id") REFERENCES "public"."season_participants"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "group_stage_matches" ADD CONSTRAINT "group_stage_matches_participant1_id_season_participants_id_fk" FOREIGN KEY ("participant1_id") REFERENCES "public"."season_participants"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "group_stage_matches" ADD CONSTRAINT "group_stage_matches_participant2_id_season_participants_id_fk" FOREIGN KEY ("participant2_id") REFERENCES "public"."season_participants"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "participant_ev_snapshots" ADD CONSTRAINT "participant_ev_snapshots_participant_id_season_participants_id_fk" FOREIGN KEY ("participant_id") REFERENCES "public"."season_participants"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "season_participant_expected_values" ADD CONSTRAINT "season_participant_expected_values_participant_id_season_participants_id_fk" FOREIGN KEY ("participant_id") REFERENCES "public"."season_participants"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "season_participant_expected_values" ADD CONSTRAINT "season_participant_expected_values_sports_season_id_sports_seasons_id_fk" FOREIGN KEY ("sports_season_id") REFERENCES "public"."sports_seasons"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "participant_golf_skills" ADD CONSTRAINT "participant_golf_skills_participant_id_season_participants_id_fk" FOREIGN KEY ("participant_id") REFERENCES "public"."season_participants"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "season_participant_qualifying_totals" ADD CONSTRAINT "season_participant_qualifying_totals_participant_id_season_participants_id_fk" FOREIGN KEY ("participant_id") REFERENCES "public"."season_participants"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "season_participant_qualifying_totals" ADD CONSTRAINT "season_participant_qualifying_totals_sports_season_id_sports_seasons_id_fk" FOREIGN KEY ("sports_season_id") REFERENCES "public"."sports_seasons"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "season_participant_results" ADD CONSTRAINT "season_participant_results_participant_id_season_participants_id_fk" FOREIGN KEY ("participant_id") REFERENCES "public"."season_participants"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "season_participant_results" ADD CONSTRAINT "season_participant_results_sports_season_id_sports_seasons_id_fk" FOREIGN KEY ("sports_season_id") REFERENCES "public"."sports_seasons"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "participant_season_results" ADD CONSTRAINT "participant_season_results_participant_id_season_participants_id_fk" FOREIGN KEY ("participant_id") REFERENCES "public"."season_participants"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "season_participant_surface_elos" ADD CONSTRAINT "season_participant_surface_elos_participant_id_season_participants_id_fk" FOREIGN KEY ("participant_id") REFERENCES "public"."season_participants"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "season_participant_surface_elos" ADD CONSTRAINT "season_participant_surface_elos_sports_season_id_sports_seasons_id_fk" FOREIGN KEY ("sports_season_id") REFERENCES "public"."sports_seasons"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "season_participants" ADD CONSTRAINT "season_participants_sports_season_id_sports_seasons_id_fk" FOREIGN KEY ("sports_season_id") REFERENCES "public"."sports_seasons"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "playoff_match_games" ADD CONSTRAINT "playoff_match_games_winner_id_season_participants_id_fk" FOREIGN KEY ("winner_id") REFERENCES "public"."season_participants"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "playoff_match_odds" ADD CONSTRAINT "playoff_match_odds_participant_id_season_participants_id_fk" FOREIGN KEY ("participant_id") REFERENCES "public"."season_participants"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "playoff_matches" ADD CONSTRAINT "playoff_matches_participant1_id_season_participants_id_fk" FOREIGN KEY ("participant1_id") REFERENCES "public"."season_participants"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "playoff_matches" ADD CONSTRAINT "playoff_matches_participant2_id_season_participants_id_fk" FOREIGN KEY ("participant2_id") REFERENCES "public"."season_participants"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "playoff_matches" ADD CONSTRAINT "playoff_matches_winner_id_season_participants_id_fk" FOREIGN KEY ("winner_id") REFERENCES "public"."season_participants"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "playoff_matches" ADD CONSTRAINT "playoff_matches_loser_id_season_participants_id_fk" FOREIGN KEY ("loser_id") REFERENCES "public"."season_participants"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "regular_season_standings" ADD CONSTRAINT "regular_season_standings_participant_id_season_participants_id_fk" FOREIGN KEY ("participant_id") REFERENCES "public"."season_participants"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "tournament_group_members" ADD CONSTRAINT "tournament_group_members_participant_id_season_participants_id_fk" FOREIGN KEY ("participant_id") REFERENCES "public"."season_participants"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "watchlist" ADD CONSTRAINT "watchlist_participant_id_season_participants_id_fk" FOREIGN KEY ("participant_id") REFERENCES "public"."season_participants"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View file

@ -0,0 +1,93 @@
CREATE TYPE "public"."tournament_status" AS ENUM('scheduled', 'in_progress', 'completed');--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "participant_surface_elos" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"participant_id" uuid NOT NULL,
"world_ranking" integer,
"elo_hard" integer,
"elo_clay" integer,
"elo_grass" integer,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "participants" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"sport_id" uuid NOT NULL,
"name" varchar(255) NOT NULL,
"external_key" varchar(255),
"metadata" jsonb,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "tournament_results" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tournament_id" uuid NOT NULL,
"participant_id" uuid NOT NULL,
"placement" integer,
"raw_score" numeric(10, 2),
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "tournaments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"sport_id" uuid NOT NULL,
"name" varchar(255) NOT NULL,
"year" integer NOT NULL,
"starts_at" timestamp,
"ends_at" timestamp,
"surface" varchar(50),
"location" varchar(255),
"status" "tournament_status" DEFAULT 'scheduled' NOT NULL,
"external_key" varchar(255),
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "scoring_events" ADD COLUMN "tournament_id" uuid;--> statement-breakpoint
ALTER TABLE "season_participants" ADD COLUMN "participant_id" uuid;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "participant_surface_elos" ADD CONSTRAINT "participant_surface_elos_participant_id_participants_id_fk" FOREIGN KEY ("participant_id") REFERENCES "public"."participants"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "participants" ADD CONSTRAINT "participants_sport_id_sports_id_fk" FOREIGN KEY ("sport_id") REFERENCES "public"."sports"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "tournament_results" ADD CONSTRAINT "tournament_results_tournament_id_tournaments_id_fk" FOREIGN KEY ("tournament_id") REFERENCES "public"."tournaments"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "tournament_results" ADD CONSTRAINT "tournament_results_participant_id_participants_id_fk" FOREIGN KEY ("participant_id") REFERENCES "public"."participants"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "tournaments" ADD CONSTRAINT "tournaments_sport_id_sports_id_fk" FOREIGN KEY ("sport_id") REFERENCES "public"."sports"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "participant_surface_elos_participant_unique" ON "participant_surface_elos" USING btree ("participant_id");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "participants_sport_name_unique" ON "participants" USING btree ("sport_id","name");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "tournament_results_tournament_participant_unique" ON "tournament_results" USING btree ("tournament_id","participant_id");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "tournaments_sport_name_year_unique" ON "tournaments" USING btree ("sport_id","name","year");--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "scoring_events" ADD CONSTRAINT "scoring_events_tournament_id_tournaments_id_fk" FOREIGN KEY ("tournament_id") REFERENCES "public"."tournaments"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "season_participants" ADD CONSTRAINT "season_participants_participant_id_participants_id_fk" FOREIGN KEY ("participant_id") REFERENCES "public"."participants"("id") ON DELETE restrict ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -610,6 +610,20 @@
"when": 1777505810235,
"tag": "0086_marvelous_infant_terrible",
"breakpoints": true
},
{
"idx": 87,
"version": "7",
"when": 1777661299612,
"tag": "0087_small_susan_delgado",
"breakpoints": true
},
{
"idx": 88,
"version": "7",
"when": 1777666830394,
"tag": "0088_cheerful_norrin_radd",
"breakpoints": true
}
]
}

View file

@ -9,6 +9,7 @@
"db:generate": "dotenv -- drizzle-kit generate",
"db:migrate": "dotenv -- drizzle-kit migrate",
"db:sync-prod": "bash scripts/sync-prod-db.sh",
"backfill:canonical": "dotenv -- tsx scripts/backfill-cli.ts",
"dev": "NODE_OPTIONS='--import ./instrument.server.mjs' dotenv -- tsx watch server.ts",
"start": "NODE_ENV=production NODE_OPTIONS='--import ./instrument.server.mjs' node dist/server.js",
"start:production": "NODE_ENV=production NODE_OPTIONS='--import ./instrument.server.mjs' node dist/server.js",

View file

@ -0,0 +1,450 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
import { runBackfill } from "../backfill-canonical-layer";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
/**
* Fixture rows used across multiple tests.
*/
const SPORT_ID = "sport-golf";
const SEASON_ID = "season-golf-2026";
const GOLF_SEASON = {
id: SEASON_ID,
sportId: SPORT_ID,
scoringPattern: "qualifying_points",
} as const;
function makeEvent(overrides: Partial<Record<string, unknown>> = {}) {
return {
id: "event-1",
sportsSeasonId: SEASON_ID,
tournamentId: null,
name: "Masters Tournament",
eventDate: "2026-04-09",
eventType: "tournament",
...overrides,
};
}
function makeSeasonParticipant(
overrides: Partial<Record<string, unknown>> = {},
) {
return {
id: "sp-1",
sportsSeasonId: SEASON_ID,
participantId: null,
name: "Scottie Scheffler",
...overrides,
};
}
/**
* Builds a fake drizzle db that routes select/insert/update calls based
* on the target table. The caller supplies per-table select-result
* arrays (one per call to that table's select()). Inserts return the
* value they were given, extended with a stub id. Updates are recorded
* but otherwise no-op.
*/
interface TableState {
/** Queue of results to return for successive select() calls on this table. */
selects?: unknown[][];
/** Rows that insert().values().returning() should yield. */
insertReturns?: unknown[];
/** Incremented on each update() call. */
updates?: { count: number };
}
interface FakeDbOptions {
tables: Map<unknown, TableState>;
/** Records every insert call: tableRef → rows seen. */
inserts?: Map<unknown, unknown[]>;
/** Records every update call: tableRef → count. */
updateCounts?: Map<unknown, number>;
}
function makeFakeDb(opts: FakeDbOptions) {
const { tables, inserts, updateCounts } = opts;
// Each select() starts a new chain that ultimately resolves to the
// next queued selects[] entry for the passed table. The chain is a
// thenable via .limit/.where resolution.
const db = {
select: vi.fn().mockImplementation(() => {
let boundTable: unknown;
const chain: Record<string, unknown> = {
from: vi.fn().mockImplementation((table: unknown) => {
boundTable = table;
return chain;
}),
where: vi.fn().mockImplementation(() => chain),
limit: vi.fn().mockImplementation(() => chain),
// Terminal: make chain thenable so `await chain` yields the queued result.
then: (resolve: (v: unknown) => unknown) => {
const state = tables.get(boundTable);
const queue = state?.selects ?? [];
const next = queue.shift() ?? [];
return Promise.resolve(next).then(resolve);
},
};
return chain;
}),
insert: vi.fn().mockImplementation((table: unknown) => {
return {
values: vi.fn().mockImplementation((row: unknown) => {
if (inserts) {
const seen = inserts.get(table) ?? [];
seen.push(row);
inserts.set(table, seen);
}
const state = tables.get(table);
const returnRows = state?.insertReturns ?? [
{ ...(row as object), id: `generated-${Math.random()}` },
];
const afterValues = {
returning: vi.fn().mockResolvedValue(returnRows),
// If the caller doesn't chain .returning(), make it awaitable anyway.
then: (resolve: (v: unknown) => unknown) =>
Promise.resolve(returnRows).then(resolve),
};
return afterValues;
}),
};
}),
update: vi.fn().mockImplementation((table: unknown) => {
if (updateCounts) {
updateCounts.set(table, (updateCounts.get(table) ?? 0) + 1);
}
return {
set: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue(undefined),
}),
};
}),
};
return db;
}
beforeEach(() => {
vi.clearAllMocks();
});
// ─── 1. empty DB no-op ─────────────────────────────────────────────────────
describe("runBackfill - empty DB", () => {
it("returns zero counts when no qualifying-points seasons exist", async () => {
const tables = new Map<unknown, TableState>([
[schema.sportsSeasons, { selects: [[]] }],
]);
vi.mocked(database).mockReturnValue(makeFakeDb({ tables }) as never);
const report = await runBackfill({ dryRun: false });
expect(report).toMatchObject({
tournamentsCreated: 0,
tournamentsLinked: 0,
participantsCreated: 0,
participantsLinked: 0,
tournamentResultsCreated: 0,
surfaceElosCreated: 0,
errors: [],
});
});
});
// ─── 2. golf window with 4 events creates 4 tournaments ───────────────────
describe("runBackfill - golf tournaments", () => {
it("creates 4 canonical tournaments for a golf season with 4 events", async () => {
const events = [
makeEvent({ id: "ev-1", name: "Masters Tournament", eventDate: "2026-04-09" }),
makeEvent({ id: "ev-2", name: "PGA Championship", eventDate: "2026-05-14" }),
makeEvent({ id: "ev-3", name: "U.S. Open", eventDate: "2026-06-18" }),
makeEvent({ id: "ev-4", name: "The Open Championship", eventDate: "2026-07-16" }),
];
const inserts = new Map<unknown, unknown[]>();
// Build per-event insertReturns so each new tournament gets its id.
const tables = new Map<unknown, TableState>([
[schema.sportsSeasons, { selects: [[GOLF_SEASON]] }],
[
schema.scoringEvents,
{
// 1st select: unlinked events; 2nd: refetch for results loop.
selects: [events, events.map((e, i) => ({ ...e, tournamentId: `t-${i + 1}` }))],
},
],
[
schema.tournaments,
{
// One select-miss per event, all return [] (no existing row).
selects: [[], [], [], []],
// One insert per event; return sequential ids.
insertReturns: [{ id: "t-1" }],
},
],
[schema.seasonParticipants, { selects: [[]] }],
[schema.eventResults, { selects: [[], [], [], []] }],
[schema.seasonParticipantSurfaceElos, { selects: [[]] }],
]);
// Because our insertReturns is consumed once per test, re-queue per event
// by overriding insert behaviour via the fake db options.
const tournamentIds = ["t-1", "t-2", "t-3", "t-4"];
let insertIdx = 0;
const db: ReturnType<typeof makeFakeDb> = makeFakeDb({ tables, inserts });
// Override insert for tournaments specifically to return sequential ids.
db.insert.mockImplementation((table: unknown) => ({
values: vi.fn().mockImplementation((row: unknown) => {
const seen = inserts.get(table) ?? [];
seen.push(row);
inserts.set(table, seen);
let returnRows: unknown[];
if (table === schema.tournaments) {
returnRows = [{ ...(row as object), id: tournamentIds[insertIdx++] }];
} else {
returnRows = [{ ...(row as object), id: "x" }];
}
return {
returning: vi.fn().mockResolvedValue(returnRows),
then: (resolve: (v: unknown) => unknown) =>
Promise.resolve(returnRows).then(resolve),
};
}),
}));
vi.mocked(database).mockReturnValue(db as never);
const report = await runBackfill({ dryRun: false });
expect(report.tournamentsCreated).toBe(4);
expect(report.tournamentsLinked).toBe(4);
expect(inserts.get(schema.tournaments)).toHaveLength(4);
expect(report.errors).toEqual([]);
});
});
// ─── 3. re-running doesn't duplicate ──────────────────────────────────────
describe("runBackfill - idempotence", () => {
it("does not create new tournaments when they already exist", async () => {
const events = [
makeEvent({ id: "ev-1", name: "Masters Tournament", eventDate: "2026-04-09" }),
];
const existingTournament = {
id: "t-existing",
sportId: SPORT_ID,
name: "Masters Tournament",
year: 2026,
};
const inserts = new Map<unknown, unknown[]>();
const updateCounts = new Map<unknown, number>();
const tables = new Map<unknown, TableState>([
[schema.sportsSeasons, { selects: [[GOLF_SEASON]] }],
[
schema.scoringEvents,
{
selects: [events, [{ ...events[0], tournamentId: "t-existing" }]],
},
],
[schema.tournaments, { selects: [[existingTournament]] }],
[schema.seasonParticipants, { selects: [[]] }],
[schema.eventResults, { selects: [[]] }],
[schema.seasonParticipantSurfaceElos, { selects: [[]] }],
]);
vi.mocked(database).mockReturnValue(
makeFakeDb({ tables, inserts, updateCounts }) as never,
);
const report = await runBackfill({ dryRun: false });
expect(report.tournamentsCreated).toBe(0);
expect(report.tournamentsLinked).toBe(1);
expect(inserts.get(schema.tournaments)).toBeUndefined();
});
});
// ─── 4. participant backfill ───────────────────────────────────────────────
describe("runBackfill - participants", () => {
it("creates canonical participants and links season_participants", async () => {
const sps = [
makeSeasonParticipant({ id: "sp-1", name: "Scottie Scheffler" }),
makeSeasonParticipant({ id: "sp-2", name: "Rory McIlroy" }),
];
const inserts = new Map<unknown, unknown[]>();
const updateCounts = new Map<unknown, number>();
const pIds = ["p-1", "p-2"];
let pIdx = 0;
const tables = new Map<unknown, TableState>([
[schema.sportsSeasons, { selects: [[GOLF_SEASON]] }],
[schema.scoringEvents, { selects: [[], []] }],
[schema.seasonParticipants, { selects: [sps] }],
[schema.participants, { selects: [[], []] }],
[schema.eventResults, { selects: [] }],
[schema.seasonParticipantSurfaceElos, { selects: [[]] }],
]);
const db = makeFakeDb({ tables, inserts, updateCounts });
db.insert.mockImplementation((table: unknown) => ({
values: vi.fn().mockImplementation((row: unknown) => {
const seen = inserts.get(table) ?? [];
seen.push(row);
inserts.set(table, seen);
let returnRows: unknown[];
if (table === schema.participants) {
returnRows = [{ ...(row as object), id: pIds[pIdx++] }];
} else {
returnRows = [{ ...(row as object), id: "x" }];
}
return {
returning: vi.fn().mockResolvedValue(returnRows),
then: (resolve: (v: unknown) => unknown) =>
Promise.resolve(returnRows).then(resolve),
};
}),
}));
vi.mocked(database).mockReturnValue(db as never);
const report = await runBackfill({ dryRun: false });
expect(report.participantsCreated).toBe(2);
expect(report.participantsLinked).toBe(2);
expect(inserts.get(schema.participants)).toHaveLength(2);
expect(updateCounts.get(schema.seasonParticipants)).toBe(2);
});
});
// ─── 5. Masters 2026 round-trip ───────────────────────────────────────────
describe("runBackfill - tournament_results from event_results", () => {
it("copies placement and rawScore but NOT qualifying_points_awarded", async () => {
const events = [
makeEvent({
id: "ev-masters",
tournamentId: "t-masters",
name: "Masters Tournament",
eventDate: "2026-04-09",
}),
];
const seasonParticipant = {
id: "sp-1",
sportsSeasonId: SEASON_ID,
participantId: "p-scottie",
name: "Scottie Scheffler",
};
const eventResult = {
id: "er-1",
scoringEventId: "ev-masters",
seasonParticipantId: "sp-1",
placement: 1,
rawScore: "-12.00",
qualifyingPointsAwarded: "100.00",
};
const inserts = new Map<unknown, unknown[]>();
const tables = new Map<unknown, TableState>([
[schema.sportsSeasons, { selects: [[GOLF_SEASON]] }],
[
schema.scoringEvents,
{
// 1st: no unlinked events (tournamentId already set)
// 2nd: refetch — events linked to tournament
selects: [[], events],
},
],
[schema.tournaments, { selects: [] }],
[schema.seasonParticipants, { selects: [[], [seasonParticipant]] }],
[schema.participants, { selects: [] }],
[schema.eventResults, { selects: [[eventResult]] }],
[schema.tournamentResults, { selects: [[]] }],
[schema.seasonParticipantSurfaceElos, { selects: [[]] }],
]);
vi.mocked(database).mockReturnValue(
makeFakeDb({ tables, inserts }) as never,
);
const report = await runBackfill({ dryRun: false });
expect(report.tournamentResultsCreated).toBe(1);
const trInserts = inserts.get(schema.tournamentResults) ?? [];
expect(trInserts).toHaveLength(1);
const inserted = trInserts[0] as Record<string, unknown>;
expect(inserted.tournamentId).toBe("t-masters");
expect(inserted.participantId).toBe("p-scottie");
expect(inserted.placement).toBe(1);
expect(inserted.rawScore).toBe("-12.00");
// CRITICAL: qualifying_points_awarded must NOT be copied.
expect(inserted).not.toHaveProperty("qualifyingPointsAwarded");
});
});
// ─── 6. Surface elo conflict detection ────────────────────────────────────
describe("runBackfill - surface elo conflicts", () => {
it("records a conflict error when two windows disagree on eloHard", async () => {
const sp = {
id: "sp-1",
sportsSeasonId: SEASON_ID,
participantId: "p-tennis-1",
name: "Carlos Alcaraz",
};
const windowElo = {
id: "se-1",
participantId: "sp-1",
sportsSeasonId: SEASON_ID,
eloHard: 2050,
eloClay: 2100,
eloGrass: 2000,
worldRanking: 2,
};
const existingCanonicalElo = {
id: "cpe-1",
participantId: "p-tennis-1",
eloHard: 2000, // differs!
eloClay: 2100,
eloGrass: 2000,
worldRanking: 2,
};
const inserts = new Map<unknown, unknown[]>();
const tables = new Map<unknown, TableState>([
[schema.sportsSeasons, { selects: [[GOLF_SEASON]] }],
[schema.scoringEvents, { selects: [[], []] }],
[schema.seasonParticipants, { selects: [[], [sp]] }],
[schema.participants, { selects: [] }],
[schema.eventResults, { selects: [] }],
[schema.seasonParticipantSurfaceElos, { selects: [[windowElo]] }],
[schema.participantSurfaceElos, { selects: [[existingCanonicalElo]] }],
]);
vi.mocked(database).mockReturnValue(
makeFakeDb({ tables, inserts }) as never,
);
const report = await runBackfill({ dryRun: false });
expect(report.surfaceElosCreated).toBe(0);
expect(report.errors).toHaveLength(1);
expect(report.errors[0]).toMatch(/conflict for participant p-tennis-1/);
expect(report.errors[0]).toMatch(/eloHard/);
// Must not have inserted a conflicting row.
expect(inserts.get(schema.participantSurfaceElos)).toBeUndefined();
});
});

View file

@ -0,0 +1,358 @@
/**
* Phase 2 one-off backfill: populate canonical tables
* (`tournaments`, `participants`, `tournament_results`,
* `participant_surface_elos`) from existing per-window data.
*
* See CLAUDE.md and the Phase 2 plan docs. Rules that this script
* MUST obey:
* - Never copy `qualifying_points_awarded` from `event_results` to
* `tournament_results`. QP stays per-window.
* - Never touch `season_participant_qualifying_totals`.
* - Abort loud (collect into `report.errors`) if two windows disagree
* on a canonical participant's surface-Elo values.
*
* `dryRun: true` means: run every read, compute every count, but never
* issue an `insert()` or `update()`.
*/
import { eq, and, isNull } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { extractTournamentIdentity } from "./backfill/match-tournament";
export interface BackfillOptions {
dryRun: boolean;
sportId?: string;
}
export interface BackfillReport {
tournamentsCreated: number;
/** Count of scoring_events whose tournament_id was (or would be) set. */
tournamentsLinked: number;
participantsCreated: number;
/** Count of season_participants whose participant_id was (or would be) set. */
participantsLinked: number;
tournamentResultsCreated: number;
surfaceElosCreated: number;
warnings: string[];
errors: string[];
}
type Db = ReturnType<typeof database>;
type SportsSeasonRow = typeof schema.sportsSeasons.$inferSelect;
type ScoringEventRow = typeof schema.scoringEvents.$inferSelect;
type SeasonParticipantRow = typeof schema.seasonParticipants.$inferSelect;
type EventResultRow = typeof schema.eventResults.$inferSelect;
type SeasonParticipantSurfaceEloRow =
typeof schema.seasonParticipantSurfaceElos.$inferSelect;
type TournamentRow = typeof schema.tournaments.$inferSelect;
type ParticipantRow = typeof schema.participants.$inferSelect;
type TournamentResultRow = typeof schema.tournamentResults.$inferSelect;
type ParticipantSurfaceEloRow =
typeof schema.participantSurfaceElos.$inferSelect;
export async function runBackfill(
opts: BackfillOptions,
): Promise<BackfillReport> {
const report: BackfillReport = {
tournamentsCreated: 0,
tournamentsLinked: 0,
participantsCreated: 0,
participantsLinked: 0,
tournamentResultsCreated: 0,
surfaceElosCreated: 0,
warnings: [],
errors: [],
};
const db = database();
// 1. Load qualifying-points seasons (optionally filtered by sport).
const whereClauses = [
eq(schema.sportsSeasons.scoringPattern, "qualifying_points"),
];
if (opts.sportId) {
whereClauses.push(eq(schema.sportsSeasons.sportId, opts.sportId));
}
const seasons = (await db
.select()
.from(schema.sportsSeasons)
.where(and(...whereClauses))) as SportsSeasonRow[];
for (const season of seasons) {
await backfillSeason(db, season, opts, report);
}
return report;
}
async function backfillSeason(
db: Db,
season: SportsSeasonRow,
opts: BackfillOptions,
report: BackfillReport,
): Promise<void> {
// ─── a. Tournament linking ────────────────────────────────────────────────
const unlinkedEvents = (await db
.select()
.from(schema.scoringEvents)
.where(
and(
eq(schema.scoringEvents.sportsSeasonId, season.id),
isNull(schema.scoringEvents.tournamentId),
),
)) as ScoringEventRow[];
for (const ev of unlinkedEvents) {
let identity;
try {
identity = extractTournamentIdentity({
name: ev.name,
eventDate: ev.eventDate,
eventType: ev.eventType,
});
} catch (e) {
report.warnings.push(
`skip scoring_event ${ev.id} (${ev.name}): ${(e as Error).message}`,
);
continue;
}
// Look up existing canonical tournament.
const [existing] = (await db
.select()
.from(schema.tournaments)
.where(
and(
eq(schema.tournaments.sportId, season.sportId),
eq(schema.tournaments.name, identity.name),
eq(schema.tournaments.year, identity.year),
),
)
.limit(1)) as TournamentRow[];
let tournamentId: string | undefined = existing?.id;
if (!existing) {
const startsAt = ev.eventDate ? new Date(ev.eventDate) : null;
const status =
startsAt && startsAt.getTime() < Date.now() ? "completed" : "scheduled";
if (!opts.dryRun) {
const [inserted] = (await db
.insert(schema.tournaments)
.values({
sportId: season.sportId,
name: identity.name,
year: identity.year,
startsAt,
status,
})
.returning()) as TournamentRow[];
tournamentId = inserted.id;
}
report.tournamentsCreated += 1;
}
if (!opts.dryRun && tournamentId) {
await db
.update(schema.scoringEvents)
.set({ tournamentId, updatedAt: new Date() })
.where(eq(schema.scoringEvents.id, ev.id));
}
report.tournamentsLinked += 1;
}
// ─── b. Participant linking ───────────────────────────────────────────────
const unlinkedParticipants = (await db
.select()
.from(schema.seasonParticipants)
.where(
and(
eq(schema.seasonParticipants.sportsSeasonId, season.id),
isNull(schema.seasonParticipants.participantId),
),
)) as SeasonParticipantRow[];
for (const sp of unlinkedParticipants) {
const [existing] = (await db
.select()
.from(schema.participants)
.where(
and(
eq(schema.participants.sportId, season.sportId),
eq(schema.participants.name, sp.name),
),
)
.limit(1)) as ParticipantRow[];
let participantId: string | undefined = existing?.id;
if (!existing) {
if (!opts.dryRun) {
const [inserted] = (await db
.insert(schema.participants)
.values({
sportId: season.sportId,
name: sp.name,
})
.returning()) as ParticipantRow[];
participantId = inserted.id;
}
report.participantsCreated += 1;
}
if (!opts.dryRun && participantId) {
await db
.update(schema.seasonParticipants)
.set({ participantId, updatedAt: new Date() })
.where(eq(schema.seasonParticipants.id, sp.id));
}
report.participantsLinked += 1;
}
// ─── c. Tournament results (copy completed event_results) ────────────────
// Refetch events — they may now have tournamentId set (if !dryRun).
const allEvents = (await db
.select()
.from(schema.scoringEvents)
.where(
eq(schema.scoringEvents.sportsSeasonId, season.id),
)) as ScoringEventRow[];
for (const ev of allEvents) {
if (!ev.tournamentId) {
// In dry-run we may not have a tournamentId yet; skip result copy.
continue;
}
const results = (await db
.select()
.from(schema.eventResults)
.where(
eq(schema.eventResults.scoringEventId, ev.id),
)) as EventResultRow[];
for (const r of results) {
// Only copy rows with real placement/rawScore data.
if (r.placement == null && r.rawScore == null) {
continue;
}
// Look up the season_participants row to get canonical participantId.
const [sp] = (await db
.select()
.from(schema.seasonParticipants)
.where(
eq(schema.seasonParticipants.id, r.seasonParticipantId),
)
.limit(1)) as SeasonParticipantRow[];
if (!sp || !sp.participantId) {
// Link step should have handled this; skip defensively.
continue;
}
// Check if a tournament_results row already exists.
const [existingResult] = (await db
.select()
.from(schema.tournamentResults)
.where(
and(
eq(schema.tournamentResults.tournamentId, ev.tournamentId),
eq(schema.tournamentResults.participantId, sp.participantId),
),
)
.limit(1)) as TournamentResultRow[];
if (existingResult) {
continue;
}
if (!opts.dryRun) {
// NOTE: intentionally do NOT copy qualifyingPointsAwarded.
await db.insert(schema.tournamentResults).values({
tournamentId: ev.tournamentId,
participantId: sp.participantId,
placement: r.placement,
rawScore: r.rawScore,
});
}
report.tournamentResultsCreated += 1;
}
}
// ─── d. Surface Elo (per-window → canonical) ─────────────────────────────
const elos = (await db
.select()
.from(schema.seasonParticipantSurfaceElos)
.where(
eq(schema.seasonParticipantSurfaceElos.sportsSeasonId, season.id),
)) as SeasonParticipantSurfaceEloRow[];
for (const elo of elos) {
const [sp] = (await db
.select()
.from(schema.seasonParticipants)
.where(
eq(schema.seasonParticipants.id, elo.participantId),
)
.limit(1)) as SeasonParticipantRow[];
if (!sp || !sp.participantId) {
continue;
}
const canonicalParticipantId = sp.participantId;
const [existingElo] = (await db
.select()
.from(schema.participantSurfaceElos)
.where(
eq(
schema.participantSurfaceElos.participantId,
canonicalParticipantId,
),
)
.limit(1)) as ParticipantSurfaceEloRow[];
if (existingElo) {
// Conflict detection: compare eloHard/eloClay/eloGrass/worldRanking.
const fields: Array<keyof ParticipantSurfaceEloRow> = [
"eloHard",
"eloClay",
"eloGrass",
"worldRanking",
];
const mismatches: string[] = [];
for (const f of fields) {
if (existingElo[f] !== elo[f as keyof SeasonParticipantSurfaceEloRow]) {
mismatches.push(
`${f}: existing=${String(existingElo[f])} vs incoming=${String(elo[f as keyof SeasonParticipantSurfaceEloRow])}`,
);
}
}
if (mismatches.length > 0) {
report.errors.push(
`conflict for participant ${canonicalParticipantId} (season ${season.id}): ${mismatches.join(", ")}`,
);
}
// Do not overwrite.
continue;
}
if (!opts.dryRun) {
await db.insert(schema.participantSurfaceElos).values({
participantId: canonicalParticipantId,
eloHard: elo.eloHard,
eloClay: elo.eloClay,
eloGrass: elo.eloGrass,
worldRanking: elo.worldRanking,
});
}
report.surfaceElosCreated += 1;
}
}

82
scripts/backfill-cli.ts Normal file
View file

@ -0,0 +1,82 @@
/**
* CLI entry point for the Phase 2 canonical backfill.
*
* Usage:
* npm run backfill:canonical -- [--dry-run | --apply] [--sport=<uuid>]
*
* Defaults to --dry-run for safety. No writes will be issued unless
* --apply is passed explicitly.
*/
import { DatabaseContext } from "~/database/context";
import { db } from "../server/db";
import { runBackfill } from "./backfill-canonical-layer";
import type { BackfillOptions } from "./backfill-canonical-layer";
function printHelp(): void {
console.log(
[
"Usage: backfill-cli [options]",
"",
"Options:",
" --dry-run Run without writing (default).",
" --apply Actually write to the database.",
" --sport=<uuid> Only backfill for the given sport id.",
" --help Show this message.",
].join("\n"),
);
}
function parseArgs(): BackfillOptions {
const args = process.argv.slice(2);
const opts: BackfillOptions = { dryRun: true }; // dry-run by default for safety
for (const a of args) {
if (a === "--apply") {
opts.dryRun = false;
} else if (a === "--dry-run") {
opts.dryRun = true;
} else if (a.startsWith("--sport=")) {
opts.sportId = a.slice("--sport=".length);
} else if (a === "--help" || a === "-h") {
printHelp();
process.exit(0);
} else {
console.error(`unknown arg: ${a}`);
process.exit(1);
}
}
return opts;
}
async function main() {
const opts = parseArgs();
console.log(
`Running backfill (dryRun=${opts.dryRun}, sportId=${opts.sportId ?? "all"})`,
);
const report = await DatabaseContext.run(db, () => runBackfill(opts));
console.log("---");
console.log(`tournamentsCreated: ${report.tournamentsCreated}`);
console.log(`tournamentsLinked: ${report.tournamentsLinked}`);
console.log(`participantsCreated: ${report.participantsCreated}`);
console.log(`participantsLinked: ${report.participantsLinked}`);
console.log(`tournamentResultsCreated: ${report.tournamentResultsCreated}`);
console.log(`surfaceElosCreated: ${report.surfaceElosCreated}`);
if (report.warnings.length) {
console.log("\nWARNINGS:");
for (const w of report.warnings) console.log(` ${w}`);
}
if (report.errors.length) {
console.log("\nERRORS:");
for (const e of report.errors) console.log(` ${e}`);
process.exit(2);
}
}
main()
.then(() => process.exit(0))
.catch((e) => {
console.error(e);
process.exit(1);
});

View file

@ -0,0 +1,44 @@
import { describe, it, expect } from "vitest";
import { extractTournamentIdentity } from "../match-tournament";
describe("extractTournamentIdentity", () => {
it("uses eventDate year when the name has no trailing year", () => {
const identity = extractTournamentIdentity({
name: "Masters Tournament",
eventDate: "2026-04-09",
eventType: "tournament",
});
expect(identity).toEqual({ name: "Masters Tournament", year: 2026 });
});
it("derives the year from eventDate for a simple tournament name", () => {
const identity = extractTournamentIdentity({
name: "Wimbledon",
eventDate: "2026-07-01",
eventType: "tournament",
});
expect(identity).toEqual({ name: "Wimbledon", year: 2026 });
});
it("strips a trailing year from the name and uses it as the canonical year", () => {
const identity = extractTournamentIdentity({
name: "Wimbledon 2026",
eventDate: "2026-07-01",
eventType: "tournament",
});
expect(identity).toEqual({ name: "Wimbledon", year: 2026 });
});
it("throws when neither the name nor eventDate supply a year", () => {
expect(() =>
extractTournamentIdentity({
name: "Wimbledon",
eventDate: null,
eventType: "tournament",
}),
).toThrow(/cannot determine year/);
});
});

View file

@ -0,0 +1,57 @@
/**
* 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.
*/
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"})`,
);
}

View file

@ -31,7 +31,7 @@ beforeEach(() => {
autodraftSettings: { findFirst: vi.fn() },
draftPicks: { findMany: vi.fn(), findFirst: vi.fn() },
draftQueue: { findMany: vi.fn() },
participants: { findMany: vi.fn() },
seasonParticipants: { findMany: vi.fn() },
seasonTemplateSports: { findMany: vi.fn() },
},
update: vi.fn().mockReturnThis(),
@ -146,7 +146,7 @@ describe('Timer Autodraft Integration', () => {
it('should fall back to highest EV when queue is empty and queueOnly is OFF', async () => {
mockDb.query.draftQueue.findMany.mockResolvedValue([]);
mockDb.query.participants.findMany.mockResolvedValue([
mockDb.query.seasonParticipants.findMany.mockResolvedValue([
{ id: 'participant-high-ev', name: 'High EV Participant', expectedValue: 1000 },
]);
@ -154,7 +154,7 @@ describe('Timer Autodraft Integration', () => {
expect(queueItems.length).toBe(0);
const queueOnly = false;
const availableParticipants = await mockDb.query.participants.findMany();
const availableParticipants = await mockDb.query.seasonParticipants.findMany();
const selectedParticipant = queueOnly ? null : availableParticipants[0];
expect(selectedParticipant).not.toBeNull();
expect(selectedParticipant?.expectedValue).toBe(1000);

View file

@ -232,18 +232,18 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer {
pickInRound: schema.draftPicks.pickInRound,
timeUsed: schema.draftPicks.timeUsed,
team: schema.teams,
participant: schema.participants,
participant: schema.seasonParticipants,
sport: schema.sports,
})
.from(schema.draftPicks)
.innerJoin(schema.teams, eq(schema.draftPicks.teamId, schema.teams.id))
.innerJoin(
schema.participants,
eq(schema.draftPicks.participantId, schema.participants.id)
schema.seasonParticipants,
eq(schema.draftPicks.participantId, schema.seasonParticipants.id)
)
.innerJoin(
schema.sportsSeasons,
eq(schema.participants.sportsSeasonId, schema.sportsSeasons.id)
eq(schema.seasonParticipants.sportsSeasonId, schema.sportsSeasons.id)
)
.innerJoin(
schema.sports,