server for refapp and refbot and other stuff
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: add POST /v1/pools/generate (sheet import pipeline)

alpine (Jul 16, 2026, 5:12 AM +0200) f208da4a d85d8b99

+169 -193
+24 -192
src/engine/pools.test.ts
··· 1 - import { test, before, after, beforeEach } from 'node:test'; 1 + import { test } from 'node:test'; 2 2 import assert from 'node:assert/strict'; 3 - import mongoose from 'mongoose'; 4 - import { MongoMemoryServer } from 'mongodb-memory-server'; 5 - import { 6 - setGeneratedPools, 7 - loadGeneratedPoolsFromDB, 8 - getGeneratedPools, 9 - getPoolByTier, 10 - getMatchPool, 11 - _resetCacheForTesting, 12 - } from './pools.js'; 13 - import { createEvent, loadActiveEvent } from './event.js'; 14 - 15 - let mongod: MongoMemoryServer; 16 - 17 - before(async () => { 18 - mongod = await MongoMemoryServer.create(); 19 - await mongoose.connect(mongod.getUri()); 20 - }); 21 - 22 - after(async () => { 23 - await mongoose.disconnect(); 24 - await mongod.stop(); 25 - }); 26 - 27 - beforeEach(async () => { 28 - await mongoose.connection.db!.dropCollection('generatedpools').catch(() => {}); 29 - await mongoose.connection.db!.dropCollection('events').catch(() => {}); 30 - }); 31 - 32 - const sampleBrackets = [ 33 - { 34 - name: 'Open', 35 - rounds: [ 36 - { 37 - name: 'Winners Round 1', 38 - tier: 1, 39 - matches: [ 40 - { charts: [{ songId: 1 }, { songId: 2 }, { songId: 3 }, { songId: 4 }, { songId: 5 }] }, 41 - { charts: [{ songId: 6 }, { songId: 7 }, { songId: 8 }, { songId: 9 }, { songId: 10 }] }, 42 - ], 43 - }, 44 - ], 45 - }, 46 - ]; 47 - 48 - const sampleTierPools: Record<string, { songId: number | null }[]> = { 49 - '1': [{ songId: 1 }, { songId: 2 }, { songId: 3 }], 50 - '2': [{ songId: 4 }, { songId: 5 }], 51 - }; 52 - 53 - test('setGeneratedPools saves to DB and populates cache', async () => { 54 - const event = await createEvent('Test'); 55 - await loadActiveEvent(); 56 - await setGeneratedPools(sampleBrackets, sampleTierPools); 57 - const pools = getGeneratedPools(); 58 - assert.ok(pools); 59 - assert.equal(pools.length, 1); 60 - assert.equal(pools[0]!.name, 'Open'); 61 - assert.equal(pools[0]!.rounds[0]!.matches.length, 2); 62 - }); 63 - 64 - test('getPoolByTier returns a copy of the tier pool', async () => { 65 - const event = await createEvent('Test'); 66 - await loadActiveEvent(); 67 - await setGeneratedPools(sampleBrackets, sampleTierPools); 68 - const pool = getPoolByTier(1); 69 - assert.equal(pool.length, 3); 70 - assert.equal(pool[0]!.songId, 1); 71 - // verify it's a copy (mutation doesn't affect cache) 72 - pool.push({ songId: 999 }); 73 - assert.equal(getPoolByTier(1).length, 3); 74 - }); 75 - 76 - test('getPoolByTier throws for unknown tier', async () => { 77 - const event = await createEvent('Test'); 78 - await loadActiveEvent(); 79 - await setGeneratedPools(sampleBrackets, sampleTierPools); 80 - assert.throws(() => getPoolByTier(99), /No tier pool found/); 81 - }); 82 - 83 - test('getMatchPool returns charts for a specific match slot', async () => { 84 - const event = await createEvent('Test'); 85 - await loadActiveEvent(); 86 - await setGeneratedPools(sampleBrackets, sampleTierPools); 87 - const charts = getMatchPool('Open', 'Winners Round 1', null, 0); 88 - assert.equal(charts.length, 5); 89 - assert.equal(charts[0]!.songId, 1); 90 - }); 91 - 92 - test('getMatchPool returns second match charts', async () => { 93 - const event = await createEvent('Test'); 94 - await loadActiveEvent(); 95 - await setGeneratedPools(sampleBrackets, sampleTierPools); 96 - const charts = getMatchPool('Open', 'Winners Round 1', null, 1); 97 - assert.equal(charts.length, 5); 98 - assert.equal(charts[0]!.songId, 6); 99 - }); 100 - 101 - test('getMatchPool throws for nonexistent bracket', async () => { 102 - const event = await createEvent('Test'); 103 - await loadActiveEvent(); 104 - await setGeneratedPools(sampleBrackets, sampleTierPools); 105 - assert.throws(() => getMatchPool('Nonexistent', 'Winners Round 1', null, 0), /Bracket "Nonexistent" not found/); 106 - }); 107 - 108 - test('getMatchPool throws for nonexistent round', async () => { 109 - const event = await createEvent('Test'); 110 - await loadActiveEvent(); 111 - await setGeneratedPools(sampleBrackets, sampleTierPools); 112 - assert.throws(() => getMatchPool('Open', 'Nonexistent', null, 0), /Round "Nonexistent" .* not found/); 113 - }); 3 + import { toCsvUrl, parseSheetCsv } from '../engine/pools.js'; 114 4 115 - test('getMatchPool throws for out-of-range index', async () => { 116 - const event = await createEvent('Test'); 117 - await loadActiveEvent(); 118 - await setGeneratedPools(sampleBrackets, sampleTierPools); 119 - assert.throws(() => getMatchPool('Open', 'Winners Round 1', null, 99), /Match index 99 out of range/); 120 - }); 121 - 122 - test('getMatchPool throws for non-numeric index', async () => { 123 - const event = await createEvent('Test'); 124 - await loadActiveEvent(); 125 - await setGeneratedPools(sampleBrackets, sampleTierPools); 126 - assert.throws(() => getMatchPool('Open', 'Winners Round 1', null, NaN as unknown as number), /Invalid match index/); 127 - }); 128 - 129 - test('getMatchPool throws when pools not generated', async () => { 130 - _resetCacheForTesting(); 131 - assert.throws(() => getMatchPool('Open', 'Winners Round 1', null, 0), /Pools have not been generated/); 5 + test('toCsvUrl converts a sheets share link to csv export', () => { 6 + assert.equal( 7 + toCsvUrl('https://docs.google.com/spreadsheets/d/1kHysko83hbvFf9EoA4B1-i_198M7ifBWkQzdwmU_W2A/edit?gid=837485183'), 8 + 'https://docs.google.com/spreadsheets/d/1kHysko83hbvFf9EoA4B1-i_198M7ifBWkQzdwmU_W2A/export?format=csv', 9 + ); 132 10 }); 133 11 134 - test('loadGeneratedPoolsFromDB restores cache from DB', async () => { 135 - const event = await createEvent('Test'); 136 - await loadActiveEvent(); 137 - await setGeneratedPools(sampleBrackets, sampleTierPools); 138 - // simulate fresh process — clear in-memory cache by re-importing would be complex, 139 - // so just verify loadGeneratedPoolsFromDB works on the same event 140 - await loadGeneratedPoolsFromDB(); 141 - const pools = getGeneratedPools(); 142 - assert.ok(pools); 143 - assert.equal(pools.length, 1); 12 + test('toCsvUrl throws on a non-sheets url', () => { 13 + assert.throws(() => toCsvUrl('https://example.com/foo'), /Invalid Google Sheets URL/); 144 14 }); 145 15 146 - test('setGeneratedPools handles brackets with poolId', async () => { 147 - const event = await createEvent('Test'); 148 - await loadActiveEvent(); 149 - const bracketsWithPool = [ 150 - { 151 - name: 'Swiss', 152 - rounds: [ 153 - { 154 - name: 'Round 1', 155 - tier: 1, 156 - matches: [ 157 - { charts: [{ songId: 1 }, { songId: 2 }] }, 158 - ], 159 - }, 160 - ], 161 - }, 162 - ]; 163 - await setGeneratedPools(bracketsWithPool); 164 - const charts = getMatchPool('Swiss', 'Round 1', null, 0); 165 - assert.equal(charts.length, 2); 166 - }); 16 + test('parseSheetCsv reads tier pools with songIds', () => { 17 + const csv = [ 18 + 'col0,song,col2,col3,spinshare,col5,col6,tier', 19 + 't0,Song A,,,,,,1', 20 + 't0,Song B,,,https://spinsha.re/song/12345,,,2', 21 + 't0,Song C,,,https://spinsha.re/song/67890,,,1', 22 + 't0,No Tier Song,,,https://spinsha.re/song/11,,,', 23 + ].join('\n'); 167 24 168 - test('getMatchPool matches poolId regardless of string/number coercion', async () => { 169 - const event = await createEvent('Test'); 170 - await loadActiveEvent(); 171 - // stored poolId is numeric (1); query arrives as a string ('1') 172 - const bracketsWithPool = [ 173 - { 174 - name: 'Swiss', 175 - rounds: [ 176 - { 177 - name: 'Round 1', 178 - poolId: 1, 179 - tier: 1, 180 - matches: [ 181 - { charts: [{ songId: 1 }, { songId: 2 }] }, 182 - ], 183 - }, 184 - { 185 - name: 'Round 1', 186 - poolId: 2, 187 - tier: 2, 188 - matches: [ 189 - { charts: [{ songId: 3 }] }, 190 - ], 191 - }, 192 - ], 193 - }, 194 - ]; 195 - await setGeneratedPools(bracketsWithPool); 196 - const charts = getMatchPool('Swiss', 'Round 1', '1', 0); 197 - assert.equal(charts.length, 2); 198 - const other = getMatchPool('Swiss', 'Round 1', '2', 0); 199 - assert.equal(other.length, 1); 25 + const pools = parseSheetCsv(csv); 26 + assert.equal(pools['1'].length, 2); 27 + assert.equal(pools['2'].length, 1); 28 + assert.deepEqual(pools['1'].map((c) => c.songId), [null, 67890]); 29 + assert.deepEqual(pools['2'].map((c) => c.songId), [12345]); 30 + // index is assigned sequentially for difficulty ordering 31 + assert.equal(pools['1'][0].songId, null); 200 32 });
+122
src/engine/pools.ts
··· 1 1 import { GeneratedPools } from '../models/index.js'; 2 + import { HttpError } from '../errors.js'; 2 3 import { getActiveEvent } from './event.js'; 4 + import { bracketsConfig } from '../external/brackets.js'; 5 + import { extractSongId, fetchAndCacheChart } from '../external/spinshare.js'; 6 + import { fetchOpenSets, type StartggSet } from '../external/startgg.js'; 7 + import { generatePools, type TierChart } from '../util/poolGenerator.js'; 3 8 import { logger } from '../logger.js'; 4 9 5 10 let generatedPools: { ··· 160 165 songId: c?.songId ?? null, 161 166 })); 162 167 } 168 + 169 + // --- Sheet import pipeline (mirrors the bot's /generate flow) --- 170 + 171 + // converts a google sheets share URL to its CSV export endpoint 172 + export function toCsvUrl(url: string): string { 173 + const match = url.match(/\/spreadsheets\/d\/([a-zA-Z0-9-_]+)/); 174 + if (!match) throw new HttpError(400, 'invalid_sheet_url', 'Invalid Google Sheets URL.'); 175 + return `https://docs.google.com/spreadsheets/d/${match[1]}/export?format=csv`; 176 + } 177 + 178 + function parseCsvLine(line: string): string[] { 179 + const cols: string[] = []; 180 + let cur = ''; 181 + let inQuotes = false; 182 + for (const ch of line) { 183 + if (ch === '"') { 184 + inQuotes = !inQuotes; 185 + } 186 + else if (ch === ',' && !inQuotes) { 187 + cols.push(cur.trim()); 188 + cur = ''; 189 + } 190 + else { 191 + cur += ch; 192 + } 193 + } 194 + cols.push(cur.trim()); 195 + return cols; 196 + } 197 + 198 + // Parses the tournament spreadsheet format: 199 + // cols[1] = song name, cols[4] = spinshare URL, cols[7] = tier. 200 + // Returns tierPools keyed by tier string with sequential index for difficulty ordering. 201 + export function parseSheetCsv(text: string): Record<string, { songId: number | null }[]> { 202 + const lines = text.split('\n').map((l) => l.trim()).filter(Boolean); 203 + const pools: Record<string, TierChart[]> = { '1': [], '2': [], '3': [], '4': [] }; 204 + 205 + for (let i = 1; i < lines.length; i++) { 206 + const cols = parseCsvLine(lines[i]); 207 + const songName = cols[1]; 208 + const spinshareUrl = cols[4]; 209 + const tier = parseInt(cols[7], 10); 210 + 211 + if (!songName || Number.isNaN(tier)) continue; 212 + if (!pools[String(tier)]) continue; 213 + 214 + pools[String(tier)]!.push({ index: pools[String(tier)]!.length, songId: extractSongId(spinshareUrl) }); 215 + } 216 + 217 + return pools as unknown as Record<string, { songId: number | null }[]>; 218 + } 219 + 220 + // Fetches chart metadata from SpinShare in the background (non-blocking), 221 + // mirroring the bot's fire-and-forget fetchAllChartData. 222 + export function backgroundFetchCharts(tierPools: Record<string, { songId: number | null }[]>): void { 223 + const songIds = [...new Set(Object.values(tierPools).flat().map((c) => c.songId).filter((id): id is number => id != null))]; 224 + let done = 0; 225 + const next = () => { 226 + if (done >= songIds.length) return; 227 + const songId = songIds[done++]!; 228 + fetchAndCacheChart(songId) 229 + .catch((err) => logger.warn({ songId, err }, 'background chart fetch failed')) 230 + .finally(() => setTimeout(next, 150)); 231 + }; 232 + next(); 233 + } 234 + 235 + export interface GenerateResult { 236 + bracketCount: number; 237 + tierCounts: Record<string, number>; 238 + chartCount: number; 239 + } 240 + 241 + // Full generate pipeline: fetch sheet CSV -> start.gg open sets -> generatePools -> save. 242 + export async function generatePoolsFromSheet( 243 + sheetUrl: string, 244 + tournamentSlug?: string | null, 245 + ): Promise<GenerateResult> { 246 + const event = getActiveEvent(); 247 + if (!event) throw new HttpError(400, 'no_active_event', 'No active event! Activate an event first.'); 248 + const slug = tournamentSlug ?? event.tournamentSlug; 249 + if (!slug) throw new HttpError(400, 'no_slug', 'Active event has no tournamentSlug set.'); 250 + 251 + const csvUrl = toCsvUrl(sheetUrl); 252 + let res: Response; 253 + try { 254 + res = await fetch(csvUrl); 255 + } 256 + catch (err) { 257 + throw new HttpError(400, 'sheet_fetch_failed', `Failed to fetch sheet: ${(err as Error).message}`); 258 + } 259 + if (!res.ok) throw new HttpError(400, 'sheet_fetch_failed', `Failed to fetch sheet: ${res.status} ${res.statusText}`); 260 + const text = await res.text(); 261 + 262 + const tierPools = parseSheetCsv(text); 263 + const tierCounts: Record<string, number> = {}; 264 + let chartCount = 0; 265 + for (const [tier, charts] of Object.entries(tierPools)) { 266 + tierCounts[tier] = charts.length; 267 + chartCount += charts.length; 268 + } 269 + 270 + const sets = await fetchOpenSets(slug); 271 + 272 + const indexedTierPools: Record<number, TierChart[]> = {}; 273 + for (const tier of Object.keys(tierPools)) { 274 + indexedTierPools[Number(tier)] = tierPools[tier] as unknown as TierChart[]; 275 + } 276 + 277 + const brackets = generatePools(indexedTierPools, bracketsConfig, sets as unknown as import('../util/poolGenerator.js').StartggSet[]); 278 + 279 + await setGeneratedPools(brackets, tierPools, String(event._id)); 280 + 281 + backgroundFetchCharts(tierPools); 282 + 283 + return { bracketCount: brackets.length, tierCounts, chartCount }; 284 + }
+8
src/external/spinshare.ts
··· 7 7 8 8 export type FetchFn = typeof fetch; 9 9 10 + // Extracts the numeric songId from a SpinShare song/share URL. 11 + // Returns null when no id is present. 12 + export function extractSongId(url?: string | null): number | null { 13 + if (!url) return null; 14 + const match = String(url).match(/spinsha\.re\/song\/(\d+)/); 15 + return match ? parseInt(match[1], 10) : null; 16 + } 17 + 10 18 export interface SpinshareChartApiData { 11 19 title: string; 12 20 artist: string;
+15 -1
src/routes/pools.ts
··· 1 1 import { Router } from 'express'; 2 - import { getGeneratedPools, getMatchPool, getPoolByTier, setGeneratedPools } from '../engine/pools.js'; 2 + import { getGeneratedPools, getMatchPool, getPoolByTier, setGeneratedPools, generatePoolsFromSheet } from '../engine/pools.js'; 3 3 import { requireAuth } from '../middleware/auth.js'; 4 4 import { param } from '../middleware/params.js'; 5 5 import { HttpError } from '../errors.js'; 6 6 7 7 export const poolsRouter = Router(); 8 + 9 + poolsRouter.post('/pools/generate', requireAuth('referee'), async (req, res, next) => { 10 + try { 11 + const { url, tournamentSlug } = req.body as { url?: string; tournamentSlug?: string | null }; 12 + if (!url || typeof url !== 'string') { 13 + throw new HttpError(400, 'missing_url', 'url (Google Sheets link) is required'); 14 + } 15 + const result = await generatePoolsFromSheet(url, tournamentSlug ?? null); 16 + res.json({ saved: true, ...result }); 17 + } 18 + catch (err) { 19 + next(err); 20 + } 21 + }); 8 22 9 23 poolsRouter.post('/pools', requireAuth('referee'), async (req, res, next) => { 10 24 try {