brackt/app/models/regular-season-standings.ts
Chris Parsons f4031d2a38
Fix standings upsert no-op: use EXCLUDED pseudo-table (#215)
* Fix upsert standings no-op: use EXCLUDED pseudo-table for conflict update

Drizzle's onConflictDoUpdate set block was referencing the existing table
columns instead of the incoming values, causing every sync to silently
overwrite records with their own current data (no-op).

Fixes #211

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Add unit tests for regular-season-standings model

Covers upsertRegularSeasonStandings (empty-array short-circuit, value
mapping, syncedAt behaviour), upsertManualStanding, getRegularSeasonStandings
sorting, getLastSyncedAt, deleteRegularSeasonStandings, and
getParticipantStanding — 19 tests total.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 21:22:53 -07:00

200 lines
6.2 KiB
TypeScript

import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and, max, sql } from "drizzle-orm";
export interface UpsertRegularSeasonStandingData {
participantId: string;
sportsSeasonId: string;
wins: number;
losses: number;
otLosses?: number | null;
ties?: number | null;
winPct?: number | null;
gamesPlayed: number;
gamesBack?: number | null;
conference?: string | null;
division?: string | null;
conferenceRank?: number | null;
divisionRank?: number | null;
leagueRank?: number | null;
streak?: string | null;
lastTen?: string | null;
homeRecord?: string | null;
awayRecord?: string | null;
externalTeamId?: string | null;
syncedAt?: Date | null;
}
/**
* Bulk upsert regular season standings records.
* Uses onConflictDoUpdate on the (participantId, sportsSeasonId) unique index.
* Sets syncedAt = now() for auto-synced records; null means manually entered.
*/
export async function upsertRegularSeasonStandings(
records: UpsertRegularSeasonStandingData[],
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
if (records.length === 0) return [];
const now = new Date();
const values = records.map((r) => ({
participantId: r.participantId,
sportsSeasonId: r.sportsSeasonId,
wins: r.wins,
losses: r.losses,
otLosses: r.otLosses ?? null,
ties: r.ties ?? null,
winPct: r.winPct !== null && r.winPct !== undefined ? r.winPct.toString() : null,
gamesPlayed: r.gamesPlayed,
gamesBack: r.gamesBack !== null && r.gamesBack !== undefined ? r.gamesBack.toString() : null,
conference: r.conference ?? null,
division: r.division ?? null,
conferenceRank: r.conferenceRank ?? null,
divisionRank: r.divisionRank ?? null,
leagueRank: r.leagueRank ?? null,
streak: r.streak ?? null,
lastTen: r.lastTen ?? null,
homeRecord: r.homeRecord ?? null,
awayRecord: r.awayRecord ?? null,
externalTeamId: r.externalTeamId ?? null,
syncedAt: r.syncedAt !== undefined ? r.syncedAt : now,
updatedAt: now,
}));
return await db.transaction(async (tx) => {
return tx
.insert(schema.regularSeasonStandings)
.values(values)
.onConflictDoUpdate({
target: [
schema.regularSeasonStandings.participantId,
schema.regularSeasonStandings.sportsSeasonId,
],
set: {
wins: sql`excluded.wins`,
losses: sql`excluded.losses`,
otLosses: sql`excluded.ot_losses`,
ties: sql`excluded.ties`,
winPct: sql`excluded.win_pct`,
gamesPlayed: sql`excluded.games_played`,
gamesBack: sql`excluded.games_back`,
conference: sql`excluded.conference`,
division: sql`excluded.division`,
conferenceRank: sql`excluded.conference_rank`,
divisionRank: sql`excluded.division_rank`,
leagueRank: sql`excluded.league_rank`,
streak: sql`excluded.streak`,
lastTen: sql`excluded.last_ten`,
homeRecord: sql`excluded.home_record`,
awayRecord: sql`excluded.away_record`,
externalTeamId: sql`excluded.external_team_id`,
syncedAt: sql`excluded.synced_at`,
updatedAt: sql`excluded.updated_at`,
},
})
.returning();
});
}
/**
* Upsert a single standing record as a manual admin override.
* Sets syncedAt = null to indicate manual entry (won't conflict with auto-sync).
*/
export async function upsertManualStanding(
participantId: string,
sportsSeasonId: string,
data: Omit<UpsertRegularSeasonStandingData, "participantId" | "sportsSeasonId" | "syncedAt">,
providedDb?: ReturnType<typeof database>
) {
const results = await upsertRegularSeasonStandings(
[{ ...data, participantId, sportsSeasonId, syncedAt: null }],
providedDb
);
return results[0];
}
/**
* Get all regular season standings for a sports season.
* Ordered by conference, division, then divisionRank (or leagueRank as fallback).
*/
export async function getRegularSeasonStandings(
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
const rows = await db.query.regularSeasonStandings.findMany({
where: eq(schema.regularSeasonStandings.sportsSeasonId, sportsSeasonId),
with: {
participant: true,
},
});
// Sort: conference → division → divisionRank ?? leagueRank ?? leagueRank, nulls last
return rows.toSorted((a, b) => {
const confA = a.conference ?? "ZZZ";
const confB = b.conference ?? "ZZZ";
if (confA !== confB) return confA.localeCompare(confB);
const divA = a.division ?? "ZZZ";
const divB = b.division ?? "ZZZ";
if (divA !== divB) return divA.localeCompare(divB);
const rankA = a.divisionRank ?? a.leagueRank ?? 999;
const rankB = b.divisionRank ?? b.leagueRank ?? 999;
return rankA - rankB;
});
}
/**
* Returns the most recent syncedAt timestamp across all records for a season.
* Returns null if no auto-synced records exist.
*/
export async function getLastSyncedAt(
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
): Promise<Date | null> {
const db = providedDb || database();
const result = await db
.select({ lastSync: max(schema.regularSeasonStandings.syncedAt) })
.from(schema.regularSeasonStandings)
.where(eq(schema.regularSeasonStandings.sportsSeasonId, sportsSeasonId));
return result[0]?.lastSync ?? null;
}
/**
* Delete all regular season standings for a sports season.
*/
export async function deleteRegularSeasonStandings(
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
await db
.delete(schema.regularSeasonStandings)
.where(eq(schema.regularSeasonStandings.sportsSeasonId, sportsSeasonId));
}
/**
* Get a single standing record for a participant in a season.
*/
export async function getParticipantStanding(
participantId: string,
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
return db.query.regularSeasonStandings.findFirst({
where: and(
eq(schema.regularSeasonStandings.participantId, participantId),
eq(schema.regularSeasonStandings.sportsSeasonId, sportsSeasonId)
),
with: { participant: true },
});
}