Upload several Melee exports at once; let admins delete leagues

- The session import takes many files (every round's matches export plus
  the standings export) in one transaction; a bad file, a round repeated
  across files, or two standings exports rejects the whole batch.
- Delete league (with its sessions and results; players are kept).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
derekandClaude Opus 5.5 committed 2026-10-28 17:11:10 +00:00
1 parent 985c85bcef
commit 0e14228b8f
3 files changed
+42 -15

No files matched your search

+37 -12
View File
@@ -176,6 +176,15 @@ app.post("/admin/league/:id/session", admin, async (req, res) => {
res.redirect(303, BASE + `/session/${s.id}`);
});
app.post("/admin/league/:id/delete", admin, async (req, res) => {
const id = idOf(req);
await tx(async (c) => {
await c.query("DELETE FROM sessions WHERE league_id=$1", [id]); // matches/results cascade; players are shared, so kept
if (!(await c.query("DELETE FROM leagues WHERE id=$1", [id])).rowCount) throw new HttpError(404, "Not found");
});
res.redirect(303, BASE + "/");
});
app.post("/admin/session/:id/delete", admin, async (req, res) => {
const [s] = await q("DELETE FROM sessions WHERE id=$1 RETURNING league_id", [idOf(req)]); // matches/results cascade
if (!s) throw new HttpError(404, "Not found");
@@ -211,20 +220,36 @@ app.post("/admin/session/:id/import", admin, async (req, res) => {
// Import a Melee matches export (replaces only the rounds in the file) or standings export (replaces the
// session's imported standings). Uploaded or pasted; decks already entered are kept.
app.post("/admin/session/:id/import-melee", admin, upload.single("file"), async (req, res) => {
// Several files at once (any mix of matches exports and at most one standings export) go in one transaction,
// so a bad file imports nothing.
app.post("/admin/session/:id/import-melee", admin, upload.array("files", 50), async (req, res) => {
const sid = idOf(req);
await find("sessions", sid);
const text = req.file ? req.file.buffer.toString("utf8") : String(req.body.data ?? "");
let parsed: { matches: Match[] } | { rows: Row[] };
try {
const table = parseTable(text);
parsed = isMatchExport(table) ? { matches: extractMatches(table) } : { rows: extract(table) };
} catch (e) { throw new HttpError(400, (e as Error).message); }
await ("matches" in parsed ? importMatches(sid, parsed.matches) : importStandings(sid, parsed.rows));
const inputs = ((req.files ?? []) as Express.Multer.File[]).map((f) => ({ name: f.originalname, text: f.buffer.toString("utf8") }));
if (String(req.body.data ?? "").trim()) inputs.push({ name: "pasted text", text: String(req.body.data) });
if (!inputs.length) throw new HttpError(400, "Choose at least one file or paste an export");
const matches: Match[] = [], standings: { name: string; rows: Row[] }[] = [], roundFrom = new Map<number, string>();
for (const { name, text } of inputs) {
try {
const table = parseTable(text);
if (!isMatchExport(table)) { standings.push({ name, rows: extract(table) }); continue; }
const ms = extractMatches(table);
for (const r of new Set(ms.map((m) => m.round))) {
if (roundFrom.has(r)) throw new Error(`round ${r} is also in ${roundFrom.get(r)}`);
roundFrom.set(r, name);
}
matches.push(...ms);
} catch (e) { throw new HttpError(400, `${name}: ${(e as Error).message}`); }
}
if (standings.length > 1) throw new HttpError(400, `Only one standings export per session: ${standings.map((s) => s.name).join(", ")}`);
await tx(async (c) => {
if (matches.length) await importMatches(c, sid, matches);
if (standings.length) await importStandings(c, sid, standings[0].rows);
});
res.redirect(303, BASE + `/session/${sid}`);
});
const importMatches = (sid: number, matches: Match[]) => tx(async (c) => {
async function importMatches(c: Q, sid: number, matches: Match[]) {
const old = new Map<number, string>((await c.query(
`SELECT p1_id AS pid, p1_deck AS deck FROM matches WHERE session_id=$1 AND p1_deck IS NOT NULL
UNION SELECT p2_id, p2_deck FROM matches WHERE session_id=$1 AND p2_deck IS NOT NULL`, [sid])).rows.map((r) => [r.pid, r.deck]));
@@ -237,9 +262,9 @@ const importMatches = (sid: number, matches: Match[]) => tx(async (c) => {
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
[sid, m.round, a, b, old.get(a) ?? null, b == null ? null : old.get(b) ?? null, m.w1, m.w2, m.draws]);
}
});
}
const importStandings = (sid: number, rows: Row[]) => tx(async (c) => {
async function importStandings(c: Q, sid: number, rows: Row[]) {
const old = new Map<number, string | null>((await c.query("SELECT player_id, deck FROM session_results WHERE session_id=$1", [sid])).rows.map((r) => [r.player_id, r.deck]));
const pid = await resolver(c);
await c.query("DELETE FROM session_results WHERE session_id=$1", [sid]);
@@ -250,7 +275,7 @@ const importStandings = (sid: number, rows: Row[]) => tx(async (c) => {
VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (session_id, player_id) DO NOTHING`,
[sid, p, r.points, r.deck || old.get(p) || null, ...(r.wdl ?? [null, null, null])]);
}
});
}
app.post("/admin/session/:id/match-decks", admin, async (req, res) => {
const sid = idOf(req);