Files
rate-my-shots/src/lib/admin-matches.ts
2026-05-03 18:39:27 +01:00

345 lines
8.4 KiB
TypeScript

import { and, asc, eq, sql } from "drizzle-orm";
import { db } from "@/db";
import { assets, eloHistory, matches, pairings } from "@/db/schema";
import { calculateElo } from "./elo";
import { ApiError } from "./errors";
const PAGE_SIZE = 20;
export interface PendingMatchEntry {
id: number;
leftId: string;
rightId: string;
leftAspectRatio: number;
rightAspectRatio: number;
winnerId: string;
winnerEloBefore: number;
loserEloBefore: number;
winnerEloAfter: number;
loserEloAfter: number;
eloDelta: number;
voterIp: string;
createdAt: string;
}
export async function getPendingMatches(
page = 0,
limit = PAGE_SIZE,
): Promise<PendingMatchEntry[]> {
const left = db
.select({
id: assets.id,
aspectRatio: assets.aspectRatio,
})
.from(assets)
.as("left");
const right = db
.select({
id: assets.id,
aspectRatio: assets.aspectRatio,
})
.from(assets)
.as("right");
const rows = await db
.select({
id: matches.id,
leftId: pairings.assetAId,
rightId: pairings.assetBId,
leftAspectRatio: left.aspectRatio,
rightAspectRatio: right.aspectRatio,
winnerId: matches.winnerId,
winnerEloBefore: matches.winnerEloBefore,
loserEloBefore: matches.loserEloBefore,
voterIp: pairings.voterIp,
createdAt: matches.createdAt,
})
.from(matches)
.innerJoin(pairings, eq(matches.pairingUuid, pairings.uuid))
.innerJoin(left, eq(pairings.assetAId, left.id))
.innerJoin(right, eq(pairings.assetBId, right.id))
.where(eq(matches.verified, false))
.orderBy(asc(matches.createdAt))
.limit(limit)
.offset(page * limit);
return rows.map((r) => {
const { newWinnerElo, newLoserElo } = calculateElo(
r.winnerEloBefore,
r.loserEloBefore,
);
return {
...r,
winnerEloAfter: newWinnerElo,
loserEloAfter: newLoserElo,
eloDelta: newWinnerElo - r.winnerEloBefore,
};
});
}
export async function getPendingCount(): Promise<number> {
const [row] = await db
.select({ count: sql<number>`COUNT(*)` })
.from(matches)
.where(eq(matches.verified, false));
return row?.count ?? 0;
}
/**
* Approve a single pending match by ID. Caller must ensure it's the oldest pending.
* Applies the match's outcome to verified ELO and increments verified counters.
*/
async function approveMatch(matchId: number) {
return db.transaction((tx) => {
const [match] = tx
.select()
.from(matches)
.where(eq(matches.id, matchId))
.all();
if (!match) throw new ApiError("match_not_found", 404);
if (match.verified) throw new ApiError("match_already_verified", 409);
const [oldest] = tx
.select({ id: matches.id })
.from(matches)
.where(eq(matches.verified, false))
.orderBy(asc(matches.createdAt))
.limit(1)
.all();
if (!oldest || oldest.id !== matchId) {
throw new ApiError("not_oldest_match", 409);
}
const [winner] = tx
.select()
.from(assets)
.where(eq(assets.id, match.winnerId))
.all();
const [loser] = tx
.select()
.from(assets)
.where(eq(assets.id, match.loserId))
.all();
if (!winner || !loser) throw new ApiError("asset_not_found", 404);
const { newWinnerElo, newLoserElo } = calculateElo(
winner.eloVerified,
loser.eloVerified,
);
tx.update(assets)
.set({
eloVerified: newWinnerElo,
matchesVerified: winner.matchesVerified + 1,
winsVerified: winner.winsVerified + 1,
})
.where(eq(assets.id, winner.id))
.run();
tx.update(assets)
.set({
eloVerified: newLoserElo,
matchesVerified: loser.matchesVerified + 1,
})
.where(eq(assets.id, loser.id))
.run();
tx.update(matches)
.set({ verified: true })
.where(eq(matches.id, matchId))
.run();
tx.insert(eloHistory)
.values([
{
assetId: winner.id,
elo: newWinnerElo,
eloType: "verified",
matchId: match.id,
},
{
assetId: loser.id,
elo: newLoserElo,
eloType: "verified",
matchId: match.id,
},
])
.run();
});
}
export async function approveOldestPending(): Promise<{
approvedId: number | null;
}> {
const [oldest] = await db
.select({ id: matches.id })
.from(matches)
.where(eq(matches.verified, false))
.orderBy(asc(matches.createdAt))
.limit(1);
if (!oldest) return { approvedId: null };
await approveMatch(oldest.id);
return { approvedId: oldest.id };
}
export async function approveAllPending(): Promise<{ approvedCount: number }> {
let approvedCount = 0;
// Approve one at a time so the verified ELO chain is built incrementally.
// Each approval is its own transaction; if one fails, prior approvals stay.
while (true) {
const [oldest] = await db
.select({ id: matches.id })
.from(matches)
.where(eq(matches.verified, false))
.orderBy(asc(matches.createdAt))
.limit(1);
if (!oldest) break;
await approveMatch(oldest.id);
approvedCount++;
}
return { approvedCount };
}
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0];
function deletePendingMatchInTx(tx: Tx, matchId: number) {
const [match] = tx
.select()
.from(matches)
.where(eq(matches.id, matchId))
.all();
if (!match) throw new ApiError("match_not_found", 404);
if (match.verified) throw new ApiError("match_already_verified", 409);
tx.delete(eloHistory)
.where(
and(
eq(eloHistory.matchId, matchId),
eq(eloHistory.eloType, "provisional"),
),
)
.run();
tx.update(assets)
.set({
matchesProvisional: sql`${assets.matchesProvisional} - 1`,
winsProvisional: sql`${assets.winsProvisional} - 1`,
})
.where(eq(assets.id, match.winnerId))
.run();
tx.update(assets)
.set({ matchesProvisional: sql`${assets.matchesProvisional} - 1` })
.where(eq(assets.id, match.loserId))
.run();
tx.delete(matches).where(eq(matches.id, matchId)).run();
}
function recomputeProvisionalInTx(tx: Tx) {
// Reset all assets' provisional ELO to their verified ELO.
tx.update(assets)
.set({ eloProvisional: sql`${assets.eloVerified}` })
.run();
// Replay remaining pending matches in chronological order.
const pending = tx
.select({
id: matches.id,
winnerId: matches.winnerId,
loserId: matches.loserId,
})
.from(matches)
.where(eq(matches.verified, false))
.orderBy(asc(matches.createdAt))
.all();
for (const p of pending) {
const [w] = tx
.select({ eloProvisional: assets.eloProvisional })
.from(assets)
.where(eq(assets.id, p.winnerId))
.all();
const [l] = tx
.select({ eloProvisional: assets.eloProvisional })
.from(assets)
.where(eq(assets.id, p.loserId))
.all();
if (!w || !l) continue;
const { newWinnerElo, newLoserElo } = calculateElo(
w.eloProvisional,
l.eloProvisional,
);
tx.update(matches)
.set({
winnerEloBefore: w.eloProvisional,
loserEloBefore: l.eloProvisional,
})
.where(eq(matches.id, p.id))
.run();
tx.update(assets)
.set({ eloProvisional: newWinnerElo })
.where(eq(assets.id, p.winnerId))
.run();
tx.update(assets)
.set({ eloProvisional: newLoserElo })
.where(eq(assets.id, p.loserId))
.run();
tx.delete(eloHistory)
.where(
and(
eq(eloHistory.matchId, p.id),
eq(eloHistory.eloType, "provisional"),
),
)
.run();
tx.insert(eloHistory)
.values([
{
assetId: p.winnerId,
elo: newWinnerElo,
eloType: "provisional",
matchId: p.id,
},
{
assetId: p.loserId,
elo: newLoserElo,
eloType: "provisional",
matchId: p.id,
},
])
.run();
}
}
/**
* Reject (delete) one or more pending matches. All deletions happen first,
* then provisional ELO is recomputed once at the end.
*/
export async function rejectMatches(
matchIds: number[],
): Promise<{ rejectedCount: number }> {
if (matchIds.length === 0) return { rejectedCount: 0 };
await db.transaction((tx) => {
for (const id of matchIds) {
deletePendingMatchInTx(tx, id);
}
recomputeProvisionalInTx(tx);
});
return { rejectedCount: matchIds.length };
}