* 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>
200 lines
6.2 KiB
TypeScript
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 },
|
|
});
|
|
}
|