345 lines
8.4 KiB
TypeScript
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 };
|
|
}
|