Repository navigation
Add competition schema and durable FRC import worker #83
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
3cf93a9
add new fields to schema
jackshim415 eb32145
Harden competition data migration to preserve scouting reports
jackshim415 04fc3e8
Add endpoint fetch state and durable import jobs
jackshim415 5702d6e
Add scheduled FRC imports and inferred tournament gaps
jackshim415 c37e7e8
Make the import worker deployable on Railway
jackshim415 fa0bd35
Fix cached worker refreshes and schema test fixtures
jackshim415 845e016
Delegate server match refreshes to the import worker
jackshim415 4c141c2
Order playoff matches and inferred gaps by play sequence
jackshim415 caabbc1
Complete competition imports and document production rollout
jackshim415 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
275 changes: 14 additions & 261 deletions
275
apps/server/src/handler/manager/addTournamentMatches.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,274 +1,27 @@ | ||
| import prismaClient from "../../prismaClient.js"; | ||
| import z from "zod"; | ||
| import axios from "axios"; | ||
| import type { AxiosResponse } from "axios"; | ||
|
|
||
| interface TbaAlliance { | ||
| team_keys: string[]; | ||
| } | ||
|
|
||
| interface TbaMatch { | ||
| actual_time: number | null; | ||
| time: number | null; | ||
| comp_level: string; | ||
| match_number: number; | ||
| key: string; | ||
| alliances: { | ||
| red: TbaAlliance; | ||
| blue: TbaAlliance; | ||
| }; | ||
| } | ||
|
|
||
| type TbaMatchesResponse = TbaMatch[]; | ||
|
|
||
| // Keep existing callers, but let the worker be the only official match writer. | ||
| export const addTournamentMatches = async ( | ||
| tournamentKey: string, | ||
| ): Promise<void> => { | ||
| try { | ||
| if (tournamentKey === undefined) { | ||
| throw "tournament key is undefined"; | ||
| } | ||
|
|
||
| if (!tournamentKey.startsWith("2026")) { | ||
| return; | ||
| } | ||
|
|
||
| const url = "https://www.thebluealliance.com/api/v3"; | ||
| const tournamentRow = await prismaClient.tournament.findUnique({ | ||
| where: { | ||
| key: tournamentKey, | ||
| }, | ||
| }); | ||
|
|
||
| if (tournamentRow === null) { | ||
| throw "tournament not found when trying to insert tournament matches"; | ||
| } | ||
| if (typeof tournamentKey !== "string" || !tournamentKey.trim()) return; | ||
|
|
||
| const eventResponse = await fetch(`${url}/event/${tournamentKey}`, { | ||
| headers: { "X-TBA-Auth-Key": process.env.TBA_KEY ?? "" }, | ||
| const tournament = await prismaClient.tournament.findUnique({ | ||
| where: { key: tournamentKey }, | ||
| select: { key: true }, | ||
| }); | ||
|
|
||
| const json: unknown = await eventResponse.json(); | ||
| if (!tournament) return; | ||
|
|
||
| console.log(JSON.stringify(json, null, 2)); | ||
|
|
||
| const event = z | ||
| .object({ | ||
| remap_teams: z.record(z.string(), z.string()).nullish(), | ||
| }) | ||
| .passthrough() | ||
| .parse(json); | ||
|
|
||
| const remap_teams = event.remap_teams ?? {}; | ||
| let matchesResponse: AxiosResponse<TbaMatchesResponse>; | ||
| try { | ||
| matchesResponse = await axios.get<TbaMatchesResponse>( | ||
| `${url}/event/${tournamentKey}/matches`, | ||
| { | ||
| headers: { | ||
| "X-TBA-Auth-Key": process.env.TBA_KEY, | ||
| "If-None-Match": tournamentRow.latestFetchETag ?? "", | ||
| }, | ||
| }, | ||
| ); | ||
| } catch (error) { | ||
| if (axios.isAxiosError(error) && error.response?.status === 304) { | ||
| return; | ||
| } else { | ||
| throw error; | ||
| } | ||
| } | ||
|
|
||
| await prismaClient.tournament.update({ | ||
| where: { | ||
| key: tournamentKey, | ||
| }, | ||
| data: { | ||
| latestFetchETag: matchesResponse.headers.etag, | ||
| }, | ||
| await prismaClient.importJob.upsert({ | ||
| where: { kind_targetKey: { kind: "matches", targetKey: tournamentKey } }, | ||
| // Preserve the worker's active lease, refresh cadence and retry backoff. | ||
| update: {}, | ||
| create: { kind: "matches", targetKey: tournamentKey, runAt: new Date() }, | ||
| }); | ||
|
|
||
| // playoff formats come from tba's event.playoff_type | ||
| // Double Elim 8 team is 10 | ||
| // Double Elim 4 team is 11 | ||
| // as per https://github.com/the-blue-alliance/the-blue-alliance/blob/main/src/backend/common/consts/playoff_type.py | ||
|
|
||
| const eightTeamDoubleElimPlayoffMatchOrder = new Map<string, number>([ | ||
| ["sf1m1", 1], | ||
| ["sf2m1", 2], | ||
| ["sf3m1", 3], | ||
| ["sf4m1", 4], | ||
| ["sf5m1", 5], | ||
| ["sf6m1", 6], | ||
| ["sf7m1", 7], | ||
| ["sf8m1", 8], | ||
| ["sf9m1", 9], | ||
| ["sf10m1", 10], | ||
| ["sf11m1", 11], | ||
| ["sf12m1", 12], | ||
| ["sf13m1", 13], | ||
| ["f1m1", 14], | ||
| ["f1m2", 15], | ||
| ]); | ||
|
|
||
| const fourTeamDoubleElimPlayoffMatchOrder = new Map<string, number>([ | ||
| ["sf1m1", 1], | ||
| ["sf2m1", 2], | ||
| ["sf3m1", 3], | ||
| ["sf4m1", 4], | ||
| ["sf5m1", 5], | ||
| ["f1m1", 6], | ||
| ["f1m2", 7], | ||
| ]); | ||
|
|
||
| const playoffMatchOrder = | ||
| event.playoff_type === 10 | ||
| ? eightTeamDoubleElimPlayoffMatchOrder | ||
| : fourTeamDoubleElimPlayoffMatchOrder; | ||
|
|
||
| // For each match in the tournament | ||
| matchesResponse.data.sort( | ||
| (a: TbaMatch, b: TbaMatch) => | ||
| (a.actual_time ?? a.time ?? 0) - (b.actual_time ?? b.time ?? 0), | ||
| ); | ||
|
|
||
| for (const match of matchesResponse.data) { | ||
| if (match.comp_level == "qm") { | ||
| //all teams in the match | ||
| const teams = [ | ||
| ...match.alliances.red.team_keys, | ||
| ...match.alliances.blue.team_keys, | ||
| ]; | ||
| let matchesString = ``; | ||
| //make matches with trailing _0, _1, _2 etc | ||
| for (let k = 0; k < teams.length; k++) { | ||
| matchesString = | ||
| matchesString + | ||
| `('${tournamentKey}_qm${match.match_number}_${k}', '${tournamentKey}', ${match.match_number}, '${teams[k]}', '${match.comp_level}'), `; | ||
| const currMatchKey = `${tournamentKey}_qm${match.match_number}_${k}`; | ||
|
|
||
| const fakeTeamKey = teams[k]; // The one TBA sends you which is potentially "fake", like frc6418B | ||
| const mapEntry = Object.entries(remap_teams).find( | ||
| (v) => v[1] === fakeTeamKey, | ||
| ); | ||
| const realTeamKey = mapEntry ? mapEntry[0] : fakeTeamKey; | ||
| const currTeam = Number(realTeamKey.substring(3)); | ||
|
|
||
| const params = z | ||
| .object({ | ||
| matchNumber: z.number(), | ||
| tournamentKey: z.string(), | ||
| key: z.string(), | ||
| teamNumber: z.number(), | ||
| }) | ||
| .safeParse({ | ||
| key: currMatchKey, | ||
| tournamentKey: tournamentKey, | ||
| matchNumber: match.match_number, | ||
| teamNumber: currTeam, | ||
| }); | ||
|
|
||
| if (!params.success) { | ||
| throw params; | ||
| } | ||
|
|
||
| //cant use currMatch key bc theres an issue with the enum | ||
| await prismaClient.teamMatchData.upsert({ | ||
| where: { | ||
| key: currMatchKey, | ||
| }, | ||
| update: { | ||
| tournamentKey: params.data.tournamentKey, | ||
| matchNumber: params.data.matchNumber, | ||
| teamNumber: params.data.teamNumber, | ||
| matchType: "QUALIFICATION", | ||
| }, | ||
| create: { | ||
| key: params.data.key, | ||
| tournamentKey: params.data.tournamentKey, | ||
| matchNumber: params.data.matchNumber, | ||
| teamNumber: params.data.teamNumber, | ||
| matchType: "QUALIFICATION", | ||
| }, | ||
| }); | ||
| } | ||
| } else { | ||
| const teams = [ | ||
| ...match.alliances.red.team_keys, | ||
| ...match.alliances.blue.team_keys, | ||
| ]; | ||
|
|
||
| if (teams.length !== 6) { | ||
| continue; | ||
| } | ||
|
|
||
| const mappedTeams: number[] = []; | ||
| let allTeamsKnown = true; | ||
| for (const teamKey of teams) { | ||
| const mapEntry = Object.entries(remap_teams).find( | ||
| (v) => v[1] === teamKey, | ||
| ); | ||
| const realTeamKey = mapEntry ? mapEntry[0] : teamKey; | ||
| const teamNumber = Number(realTeamKey.substring(3)); | ||
| if (!Number.isFinite(teamNumber) || teamNumber <= 0) { | ||
| allTeamsKnown = false; | ||
| break; | ||
| } | ||
| mappedTeams.push(teamNumber); | ||
| } | ||
|
|
||
| if (!allTeamsKnown) { | ||
| continue; | ||
| } | ||
|
|
||
| const matchSuffix = match.key.split("_")[1] ?? ""; | ||
| const matchNumber = playoffMatchOrder.get(matchSuffix); | ||
| if (!matchNumber) { | ||
| continue; | ||
| } | ||
|
|
||
| for (let k = 0; k < 6; k++) { | ||
| const currTeam = mappedTeams[k]; | ||
|
|
||
| const currMatchKey = `${tournamentKey}_em${matchNumber}_${k}`; | ||
|
|
||
| const params = z | ||
| .object({ | ||
| matchNumber: z.number(), | ||
| tournamentKey: z.string(), | ||
| key: z.string(), | ||
| teamNumber: z.number(), | ||
| }) | ||
| .parse({ | ||
| key: currMatchKey, | ||
| tournamentKey: tournamentKey, | ||
| matchNumber: matchNumber, | ||
| teamNumber: currTeam, | ||
| }); | ||
|
|
||
| //cant use currMatch key bc theres an issue with the enum | ||
| await prismaClient.teamMatchData.upsert({ | ||
| where: { | ||
| key: currMatchKey, | ||
| }, | ||
| update: { | ||
| tournamentKey: params.tournamentKey, | ||
| matchNumber: params.matchNumber, | ||
| teamNumber: params.teamNumber, | ||
| matchType: "ELIMINATION", | ||
| }, | ||
| create: { | ||
| key: params.key, | ||
| tournamentKey: params.tournamentKey, | ||
| matchNumber: params.matchNumber, | ||
| teamNumber: params.teamNumber, | ||
| matchType: "ELIMINATION", | ||
| }, | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| } catch (error) { | ||
| console.log(error); | ||
| } catch { | ||
| // A failed enqueue must not prevent callers from reading stored matches. | ||
| console.error("Unable to queue tournament match refresh"); | ||
| } | ||
| }; | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a scouting client submits a report before its
TeamMatchDatarow has been stored, bothaddScoutReport.tsandaddScoutReportDashboard.tsawait this function and immediately repeat the lookup, assuming the await means the import finished. This implementation only inserts a job and returns, so the second lookup normally still misses and rejects the report with 404 even when the match exists in TBA; an already-scheduled job may not even run immediately becauseupdate: {}preserves its futurerunAt. The async handoff needs a pending/retry flow in these write endpoints, or it must otherwise preserve the prior guarantee that the row exists before returning.Useful? React with 👍 / 👎.