server for refapp and refbot and other stuff
0

Configure Feed

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

fix: start.gg participants query — required query arg + Discord via requiredConnections

alpine (Jul 16, 2026, 7:46 AM +0200) 2a0504b0 8686e3a0

+81 -69
+3 -3
src/engine/playerImport.test.ts
··· 75 75 await loadActiveEvent(); 76 76 77 77 const fetchFn = mockParticipantsFetch([ 78 - { id: 1, gamerTag: 'Alice', user: { discordId: '111', discriminator: '0001' } }, 79 - { id: 2, gamerTag: 'Bob', user: null }, 80 - { id: 3, gamerTag: 'Carol', user: { discordId: '333', discriminator: '0003' } }, 78 + { id: 1, gamerTag: 'Alice', requiredConnections: [{ type: 'DISCORD', externalId: '111' }] }, 79 + { id: 2, gamerTag: 'Bob', requiredConnections: null }, 80 + { id: 3, gamerTag: 'Carol', requiredConnections: [{ type: 'DISCORD', externalId: '333' }] }, 81 81 ]); 82 82 83 83 // the HTTP equivalent returns immediately with the taskId; the importing
+6 -5
src/engine/playerImport.ts
··· 16 16 } 17 17 18 18 // Build the discordId we key the Player on. Players who linked Discord to 19 - // start.gg expose `user.discordId`; everyone else gets a stable synthetic id 20 - // derived from the start.gg participant id so the record is addressable and 21 - // staff can later edit in the real Discord id. 19 + // start.gg expose `discordId` (from their DISCORD ProfileAuthorization); 20 + // everyone else gets a stable synthetic id derived from the start.gg 21 + // participant id so the record is addressable and staff can later edit in the 22 + // real Discord id. 22 23 function playerDiscordId(p: StartggParticipant): string { 23 - return p.user?.discordId ?? `startgg:${p.id}`; 24 + return p.discordId ?? `startgg:${p.id}`; 24 25 } 25 26 26 27 async function upsertParticipant(p: StartggParticipant, eventId: string): Promise<{ imported: boolean; linked: boolean }> { ··· 89 90 const p = participants[i]!; 90 91 const result = await upsertParticipant(p, eventId); 91 92 if (result.imported) imported++; 92 - if (!p.user?.discordId) needDiscord++; 93 + if (!p.discordId) needDiscord++; 93 94 emitTaskProgress({ 94 95 taskId, 95 96 stage: 'importing',
+22 -18
src/external/startgg.test.ts
··· 147 147 assert.equal(out.length, 0); 148 148 }); 149 149 150 - function participantsFixture(nodes: unknown[], totalPages = 1, page = 1) { 150 + function participantsFixture(nodes: unknown[]) { 151 151 return { 152 152 data: { 153 153 tournament: { 154 154 participants: { 155 - pageInfo: { totalPages, page, perPage: 50 }, 155 + pageInfo: { totalPages: 1, page: 1, perPage: 50 }, 156 156 nodes, 157 157 }, 158 158 }, ··· 160 160 }; 161 161 } 162 162 163 - test('fetchParticipants: returns gamerTag + user.discordId when linked', async () => { 163 + test('fetchParticipants: returns gamerTag + discordId from DISCORD connection', async () => { 164 164 const fetchFn = mockFetch(participantsFixture([ 165 - { id: 1, gamerTag: 'Alice', user: { discordId: '111', discriminator: '0001' } }, 166 - { id: 2, gamerTag: 'Bob', user: { discordId: '222', discriminator: '0002' } }, 165 + { id: 1, gamerTag: 'Alice', requiredConnections: [{ type: 'DISCORD', externalId: '111' }] }, 166 + { id: 2, gamerTag: 'Bob', requiredConnections: [{ type: 'TWITCH', externalId: 'bob' }, { type: 'DISCORD', externalId: '222' }] }, 167 167 ])); 168 168 const out = await fetchParticipants('cup', fetchFn); 169 169 assert.equal(out.length, 2); 170 170 assert.equal(out[0]!.gamerTag, 'Alice'); 171 - assert.equal(out[0]!.user?.discordId, '111'); 172 - assert.equal(out[1]!.user?.discordId, '222'); 171 + assert.equal(out[0]!.discordId, '111'); 172 + assert.equal(out[1]!.discordId, '222'); 173 173 }); 174 174 175 - test('fetchParticipants: tolerates null user (no linked Discord)', async () => { 175 + test('fetchParticipants: discordId is null when no DISCORD connection', async () => { 176 176 const fetchFn = mockFetch(participantsFixture([ 177 - { id: 3, gamerTag: 'Carol', user: null }, 177 + { id: 3, gamerTag: 'Carol', requiredConnections: [{ type: 'TWITCH', externalId: 'carol' }] }, 178 + { id: 4, gamerTag: 'Dan', requiredConnections: null }, 178 179 ])); 179 180 const out = await fetchParticipants('cup', fetchFn); 180 - assert.equal(out.length, 1); 181 - assert.equal(out[0]!.user, null); 181 + assert.equal(out.length, 2); 182 + assert.equal(out[0]!.discordId, null); 183 + assert.equal(out[1]!.discordId, null); 182 184 }); 183 185 184 - test('fetchParticipants: paginates across multiple pages', async () => { 185 - let call = 0; 186 - const fetchFn = ((async (_url: string, _opts: unknown) => { 187 - call++; 188 - if (call === 1) return { ok: true, status: 200, json: () => Promise.resolve(participantsFixture([{ id: 1, gamerTag: 'A', user: null }], 2, 1)) } as unknown as Response; 189 - return { ok: true, status: 200, json: () => Promise.resolve(participantsFixture([{ id: 2, gamerTag: 'B', user: null }], 2, 2)) } as unknown as Response; 186 + test('fetchParticipants: fetches the connection in a single call (no page args)', async () => { 187 + let calls = 0; 188 + const fetchFn = ((async () => { 189 + calls++; 190 + return { ok: true, status: 200, json: () => Promise.resolve(participantsFixture([ 191 + { id: 1, gamerTag: 'A', requiredConnections: null }, 192 + { id: 2, gamerTag: 'B', requiredConnections: null }, 193 + ])) } as unknown as Response; 190 194 }) as unknown as FetchFn); 191 195 const out = await fetchParticipants('cup', fetchFn); 192 196 assert.equal(out.length, 2); 193 - assert.equal(out[1]!.gamerTag, 'B'); 197 + assert.equal(calls, 1, 'should call start.gg exactly once'); 194 198 }); 195 199 196 200 test('fetchParticipants: returns empty when tournament is null', async () => {
+50 -43
src/external/startgg.ts
··· 17 17 export interface StartggParticipant { 18 18 id: number; 19 19 gamerTag: string; 20 - user: { 21 - discordId: string | null; 22 - discriminator: string | null; 23 - } | null; 20 + // Discord account id (snowflake) when the player linked Discord to start.gg, 21 + // sourced from the participant's DISCORD ProfileAuthorization.externalId. 22 + // `null` when no Discord account is connected. 23 + discordId: string | null; 24 24 } 25 25 26 26 // Fetches all participants/entrants for a tournament. On the alpha schema the 27 - // field is still `participants` (renamed to `entrants` on newer versions). 28 - // `user.discordId` is only present when the player linked Discord to start.gg. 27 + // `participants` field only accepts `query`/`isAdmin` (no server-side page 28 + // args); it returns a single-page ParticipantConnection, so we read it in one 29 + // call. `user.discordId` is only present when the player linked Discord. 29 30 const PARTICIPANTS_QUERY = ` 30 - query TournamentParticipants($slug: String!, $page: Int!, $perPage: Int!) { 31 + query TournamentParticipants($slug: String!, $query: ParticipantPaginationQuery!) { 31 32 tournament(slug: $slug) { 32 - participants(page: $page, perPage: $perPage) { 33 + participants(query: $query) { 33 34 pageInfo { 34 35 totalPages 35 36 page ··· 38 39 nodes { 39 40 id 40 41 gamerTag 41 - user { 42 - discordId 43 - discriminator 42 + requiredConnections { 43 + type 44 + externalId 44 45 } 45 46 } 46 47 } ··· 211 212 return sets; 212 213 } 213 214 214 - // Fetches every participant for a tournament, paginating over `participants` 215 - // (perPage is capped at 50 on the alpha schema). Returns lightweight records 216 - // with the start.gg participant id, gamerTag, and the linked Discord id when 217 - // the player connected their Discord account. `fetchFn` is injectable for tests. 215 + // Fetches every participant for a tournament in a single connection call 216 + // (the alpha schema does not support server-side pagination on `participants`). 217 + // Returns lightweight records with the start.gg participant id, gamerTag, and 218 + // the linked Discord id when the player connected their Discord account. 219 + // `fetchFn` is injectable for tests. 218 220 export async function fetchParticipants( 219 221 tournamentSlug: string, 220 222 fetchFn: FetchFn = fetch, 221 223 ): Promise<StartggParticipant[]> { 222 - const perPage = 50; 223 - const out: StartggParticipant[] = []; 224 - let page = 1; 225 - let totalPages = 1; 224 + const data = await startggQuery<{ 225 + tournament: { 226 + participants: { 227 + pageInfo: { totalPages: number; page: number; perPage: number }; 228 + nodes: RawParticipant[]; 229 + }; 230 + } | null; 231 + }>( 232 + PARTICIPANTS_QUERY, 233 + { slug: tournamentSlug, query: { page: 1, perPage: 50 } }, 234 + fetchFn, 235 + ); 226 236 227 - do { 228 - const data = await startggQuery<{ 229 - tournament: { 230 - participants: { 231 - pageInfo: { totalPages: number; page: number; perPage: number }; 232 - nodes: StartggParticipant[]; 233 - }; 234 - } | null; 235 - }>( 236 - PARTICIPANTS_QUERY, 237 - { slug: tournamentSlug, page, perPage }, 238 - fetchFn, 239 - ); 237 + if (!data.tournament) { 238 + logger.warn({ tournamentSlug }, 'startgg tournament not found'); 239 + return []; 240 + } 240 241 241 - if (!data.tournament) { 242 - logger.warn({ tournamentSlug }, 'startgg tournament not found'); 243 - break; 244 - } 242 + const nodes = data.tournament.participants.nodes.map((p) => { 243 + // Discord id lives on the DISCORD ProfileAuthorization (often duplicated 244 + // in requiredConnections), so take the first non-null externalId. 245 + const discord = p.requiredConnections?.find((c) => c.type === 'DISCORD' && c.externalId); 246 + return { 247 + id: p.id, 248 + gamerTag: p.gamerTag, 249 + discordId: discord?.externalId ?? null, 250 + } satisfies StartggParticipant; 251 + }); 245 252 246 - const conn = data.tournament.participants; 247 - out.push(...conn.nodes); 248 - totalPages = conn.pageInfo.totalPages; 249 - page++; 250 - } while (page <= totalPages); 253 + logger.debug({ tournamentSlug, count: nodes.length }, 'startgg fetchParticipants'); 254 + return nodes; 255 + } 251 256 252 - logger.debug({ tournamentSlug, count: out.length }, 'startgg fetchParticipants'); 253 - return out; 257 + interface RawParticipant { 258 + id: number; 259 + gamerTag: string; 260 + requiredConnections?: { type: string; externalId: string | null }[] | null; 254 261 }