diff --git a/__tests__/csvAlgorithm.tableAssignment.test.ts b/__tests__/csvAlgorithm.tableAssignment.test.ts new file mode 100644 index 000000000..b01013250 --- /dev/null +++ b/__tests__/csvAlgorithm.tableAssignment.test.ts @@ -0,0 +1,114 @@ +import { validateCsvBlob } from '@utils/csv-ingestion/csvAlgorithm'; + +type TeamRow = { + tableNumber: number; + projectTitle: string; + status: string; + primaryTrack: string; +}; + +function buildCsv(rows: TeamRow[]): string { + const header = [ + 'Table Number', + 'Project Status', + 'Project Title', + 'Track #1 (Primary Track)', + 'Track #2', + 'Track #3', + 'Opt-In Prizes', + ].join(','); + + const lines = rows.map((row) => + [ + String(row.tableNumber), + row.status, + row.projectTitle, + row.primaryTrack, + '', + '', + '', + ].join(',') + ); + + return `${header}\n${lines.join('\n')}`; +} + +describe('csvAlgorithm table assignment', () => { + it('assigns the first hardware team to A1', async () => { + const csv = buildCsv([ + { + tableNumber: 1, + status: 'Submitted', + projectTitle: 'Hardware Team', + primaryTrack: 'Best Hardware Hack', + }, + { + tableNumber: 2, + status: 'Submitted', + projectTitle: 'Other Team', + primaryTrack: 'Best AI/ML Hack', + }, + ]); + + const res = await validateCsvBlob(new Blob([csv], { type: 'text/csv' })); + + expect(res.ok).toBe(true); + expect(res.body).not.toBeNull(); + + const hardwareTeam = res.body?.find((t) => t.name === 'Hardware Team'); + expect(hardwareTeam?.tableNumber).toBe('A1'); + }); + + it('starts floor 2 at I1 after floor 1 capacity is filled', async () => { + // Floor 1: 8 rows * 13 seats = 104. Floor 2 starts at 105th team + const rows: TeamRow[] = Array.from({ length: 105 }, (_, i) => ({ + tableNumber: i + 1, + status: 'Submitted', + projectTitle: `Team ${i + 1}`, + primaryTrack: 'Best AI/ML Hack', + })); + + const csv = buildCsv(rows); + const res = await validateCsvBlob(new Blob([csv], { type: 'text/csv' })); + + if (!res.ok) { + console.error('Unexpected error:', res.error); + } + expect(res.ok).toBe(true); + expect(res.body).not.toBeNull(); + expect(res.body?.length).toBe(105); + // Last team on floor 1 + expect(res.body?.[103].tableNumber).toBe('H13'); + // First team on floor 2 + expect(res.body?.[104].tableNumber).toBe('I1'); + }); + + it('returns a global warning when team count exceeds venue capacity', async () => { + // Total capacity: 104 (F1) + 60 (F2) = 164 + // We provide 167 teams to trigger WAIT-n overflow labels. + const rows: TeamRow[] = Array.from({ length: 167 }, (_, i) => ({ + tableNumber: i + 1, + status: 'Submitted', + projectTitle: `Team ${i + 1}`, + primaryTrack: 'Best AI/ML Hack', + })); + + const csv = buildCsv(rows); + const res = await validateCsvBlob(new Blob([csv], { type: 'text/csv' })); + + // Capacity overflow is non-blocking and surfaced as a global warning. + expect(res.ok).toBe(true); + expect(res.error).toBeNull(); + expect(res.report.errorRows).toBe(0); + expect(res.report.warningRows).toBe(0); + expect(res.report.globalWarnings).toBeDefined(); + expect(res.report.globalWarnings?.[0]).toContain('Capacity Exceeded'); + + // Verify that even with overflow, data was processed. + expect(res.body).toHaveLength(167); + + // Verify the last team got a WAIT label. + const lastTeam = res.body?.[166]; + expect(lastTeam?.tableNumber).toBe('WAIT-3'); + }); +}); diff --git a/__tests__/logic/ingestTeams.test.ts b/__tests__/logic/ingestTeams.test.ts index 78e862aff..bd37df41d 100644 --- a/__tests__/logic/ingestTeams.test.ts +++ b/__tests__/logic/ingestTeams.test.ts @@ -23,14 +23,14 @@ describe('ingestTeams', () => { { name: 'Team 1', teamNumber: 1, - tableNumber: 1, + tableNumber: 'A1', tracks: ['Track A'], active: true, }, { name: 'Team 2', teamNumber: 2, - tableNumber: 2, + tableNumber: 'A2', tracks: ['Track B'], active: true, }, @@ -74,7 +74,7 @@ describe('ingestTeams', () => { { name: 'Invalid Team', teamNumber: 999, - tableNumber: 999, + tableNumber: '999', tracks: ['Invalid Track'], active: true, }, @@ -99,14 +99,14 @@ describe('ingestTeams', () => { { name: 'Team 1', teamNumber: 1, - tableNumber: 1, + tableNumber: 'A1', tracks: ['Track A'], active: true, }, { name: 'Team 1 Duplicate', teamNumber: 1, - tableNumber: 2, + tableNumber: 'A2', tracks: ['Track B'], active: true, }, @@ -131,7 +131,7 @@ describe('ingestTeams', () => { { name: 'Solo Team', teamNumber: 1, - tableNumber: 1, + tableNumber: 'A1', tracks: ['Track A', 'Track B'], active: true, }, @@ -157,7 +157,7 @@ describe('ingestTeams', () => { { name: 'Complete Team', teamNumber: 42, - tableNumber: 10, + tableNumber: 'A10', tracks: ['Track A', 'Track B', 'Track C'], active: false, }, diff --git a/__tests__/logic/validateCSV.test.ts b/__tests__/logic/validateCSV.test.ts index cfa1fb591..816d7293a 100644 --- a/__tests__/logic/validateCSV.test.ts +++ b/__tests__/logic/validateCSV.test.ts @@ -40,7 +40,7 @@ describe('validateCSV', () => { { name: 'Test Project', teamNumber: 1, - tableNumber: 1, + tableNumber: 'A1', tracks: ['Track A'], active: true, }, @@ -134,7 +134,7 @@ describe('validateCSV', () => { { name: 'Test', teamNumber: 1, - tableNumber: 1, + tableNumber: 'A1', tracks: ['Track A'], active: true, }, @@ -178,7 +178,7 @@ describe('validateCSV', () => { { name: 'Test', teamNumber: 1, - tableNumber: 1, + tableNumber: 'A1', tracks: ['Track A'], active: true, }, @@ -218,14 +218,14 @@ describe('validateCSV', () => { { name: 'Valid Team', teamNumber: 1, - tableNumber: 1, + tableNumber: 'A1', tracks: ['Track A'], active: true, }, { name: 'Invalid Team', teamNumber: NaN, - tableNumber: 2, + tableNumber: 'A2', tracks: ['Track B'], active: true, }, @@ -235,7 +235,7 @@ describe('validateCSV', () => { { name: 'Valid Team', teamNumber: 1, - tableNumber: 1, + tableNumber: 'A1', tracks: ['Track A'], active: true, }, diff --git a/__tests__/parseInviteCSV.test.ts b/__tests__/parseInviteCSV.test.ts index dec3e9ef3..04f4dbb12 100644 --- a/__tests__/parseInviteCSV.test.ts +++ b/__tests__/parseInviteCSV.test.ts @@ -36,8 +36,7 @@ describe('parseInviteCSV', () => { }); it('detects header with "first" keyword', () => { - const csv = - 'First,Last,Contact\nAlice,Smith,alice@example.com\n'; + const csv = 'First,Last,Contact\nAlice,Smith,alice@example.com\n'; const result = parseInviteCSV(csv); expect(result.ok).toBe(true); @@ -165,8 +164,7 @@ describe('parseInviteCSV', () => { it('handles quoted fields with commas', () => { const csv = - 'First Name,Last Name,Email\n' + - '"Alice, Jr.",Smith,alice@example.com\n'; + 'First Name,Last Name,Email\n' + '"Alice, Jr.",Smith,alice@example.com\n'; const result = parseInviteCSV(csv); expect(result.ok).toBe(true); diff --git a/__tests__/processBulkInvites.test.ts b/__tests__/processBulkInvites.test.ts index 4653059d2..3063d40f6 100644 --- a/__tests__/processBulkInvites.test.ts +++ b/__tests__/processBulkInvites.test.ts @@ -107,10 +107,12 @@ describe('processBulkInvites', () => { it('uses preprocess to filter items and include early results', async () => { mockParseCSV.mockReturnValue({ ok: true, body: [ALICE, BOB, CHARLIE] }); - const processOne = jest.fn(async (item: InviteData): Promise => ({ - email: item.email, - success: true, - })); + const processOne = jest.fn( + async (item: InviteData): Promise => ({ + email: item.email, + success: true, + }) + ); const result = await processBulkInvites('csv', { label: 'Test', diff --git a/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts b/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts index 7cfddb72d..ea18fff1e 100644 --- a/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts +++ b/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts @@ -40,6 +40,7 @@ export type CsvValidationReport = { warningRows: number; unknownTracks: string[]; issues: CsvRowIssue[]; + globalWarnings?: string[]; }; type TrackMatchCandidate = { @@ -329,6 +330,84 @@ export function sortTracks( return ordered; } +// Max teams per row on Floor 1 (Prioritize hardware teams) +const FLOOR1_ROWS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']; +const FLOOR1_TEAMS_PER_ROW = 13; + +// Max teams per row on Floor 2 +const FLOOR2_ROWS = ['I', 'J', 'K', 'L']; +const FLOOR2_TEAMS_PER_ROW = 15; + +/** + * Assigns teams to seats row by row, filling earlier rows and lower seat numbers first + * + * Each team's tableNumber is set to e.g. "A3", "B1" + */ + +function distributeAcrossRows( + teams: ParsedRecord[], + rows: string[], + maxSeats: number +) { + if (teams.length === 0 || rows.length === 0) return; + let teamIdx = 0; + for (const rowLabel of rows) { + for (let seat = 1; seat <= maxSeats; seat++) { + if (teamIdx >= teams.length) return; + // Assign the string label (e.g., "A13" or "I15") + teams[teamIdx].tableNumber = `${rowLabel}${seat}`; + teamIdx++; + } + } +} + +/** + * Assigns two-floor lettered table numbers: + * + * Floor 1 — Hardware Hack teams first, with leftover seats filled by other teams in .csv order. + * + * Floor 2 — remaining other teams after floor 1 spillover is accounted for. + * Rows start immediately after floor 1's last letter. + */ +function assignTableNumbers( + hardwareTeams: ParsedRecord[], + otherTeams: ParsedRecord[] +): void { + const allTeams = [...hardwareTeams, ...otherTeams]; + // How many seats are available on floor 1 vs how many hardware teams fill them. + const floor1Capacity = FLOOR1_ROWS.length * FLOOR1_TEAMS_PER_ROW; + const floor2Capacity = FLOOR2_ROWS.length * FLOOR2_TEAMS_PER_ROW; + const totalCapacity = floor1Capacity + floor2Capacity; + + if (hardwareTeams.length > floor1Capacity) { + console.warn( + `[CSV ingestion]: More hardware teams (${hardwareTeams.length}) than floor 1 capacity (${floor1Capacity}).` + ); + } + + // Slice teams based on physical floor capacity + const floor1Teams = allTeams.slice(0, floor1Capacity); + const floor2Teams = allTeams.slice( + floor1Capacity, + floor1Capacity + FLOOR2_ROWS.length * FLOOR2_TEAMS_PER_ROW + ); + + // Distribute teams across each floor's rows + distributeAcrossRows(floor1Teams, FLOOR1_ROWS, FLOOR1_TEAMS_PER_ROW); + distributeAcrossRows(floor2Teams, FLOOR2_ROWS, FLOOR2_TEAMS_PER_ROW); + + // If there are more teams than total capacity, assign "WAIT-{n}" table numbers to the overflow teams. + const overflowTeams = allTeams.slice(totalCapacity); + if (overflowTeams.length > 0) { + overflowTeams.forEach((team, index) => { + team.tableNumber = `WAIT-${index + 1}`; + }); + console.warn( + `[CSV ingestion]: Total teams (${allTeams.length}) exceed capacity (${totalCapacity}).` + ); + } +} + export async function validateCsvBlob(blob: Blob): Promise<{ ok: boolean; body: ParsedRecord[] | null; @@ -458,7 +537,7 @@ export async function validateCsvBlob(blob: Blob): Promise<{ output.push({ name: projectTitle, teamNumber: parsedTeamNumber, - tableNumber: 0, // assigned after ordering + tableNumber: '0', // assigned after ordering tracks: canonicalTracks, active: true, _rowIndex: rowIndex, // Store original CSV row index for error filtering @@ -473,13 +552,10 @@ export async function validateCsvBlob(blob: Blob): Promise<{ (team) => !team.tracks.includes('Best Hardware Hack') ); - const orderedTeams = [...bestHardwareTeams, ...otherTeams]; - - orderedTeams.forEach((team, index) => { - team.tableNumber = index + 1; - }); - - resolve(orderedTeams); + assignTableNumbers(bestHardwareTeams, otherTeams); + const finalResults = [...bestHardwareTeams, ...otherTeams]; + // Hardware teams first in the returned list for display ordering. + resolve(finalResults); }) .on('error', (error) => reject(error)); }; @@ -487,6 +563,19 @@ export async function validateCsvBlob(blob: Blob): Promise<{ parseBlob().catch(reject); }); + const overflowCount = results.filter((t) => + String(t.tableNumber).startsWith('WAIT-') + ).length; //count how many teams have no table seating + const globalWarnings = + overflowCount > 0 + ? [ + `Capacity Exceeded: CSV has ${ + results.length + } teams, but venue only has ${ + results.length - overflowCount + } seats. Overflow teams were labeled WAIT-*.`, + ] + : []; const errorRows = issues.filter((i) => i.severity === 'error').length; const warningRows = issues.filter((i) => i.severity === 'warning').length; @@ -519,6 +608,7 @@ export async function validateCsvBlob(blob: Blob): Promise<{ warningRows, unknownTracks: Array.from(unknownTrackSet).sort(), issues, + globalWarnings, }; const ok = report.errorRows === 0; @@ -538,6 +628,7 @@ export async function validateCsvBlob(blob: Blob): Promise<{ warningRows: 0, unknownTracks: [], issues: [], + globalWarnings: [], }; return { ok: false, diff --git a/app/(api)/_utils/matching/randomizeProjects.ts b/app/(api)/_utils/matching/randomizeProjects.ts index 8f6587f0e..3f0fce4fb 100644 --- a/app/(api)/_utils/matching/randomizeProjects.ts +++ b/app/(api)/_utils/matching/randomizeProjects.ts @@ -29,8 +29,29 @@ const groupByJudge = ( return acc; }; +// Helper to convert table letter-number format to a comparable numeric value (ex: A1 -> 1001, B3 -> 2003) +function toComparableTableNumber(tableNumber: string): number | null { + const raw = tableNumber.trim(); + if (!raw) return null; + + // If table number is already numeric + const numeric = Number(raw); + if (Number.isFinite(numeric)) return numeric; + + // Expect a single row letter (A–L) followed by a positive seat number + const match = raw.match(/^([A-L])(\d+)$/i); + if (!match) return null; + + const row = match[1].toUpperCase(); + const seat = Number(match[2]); + if (!Number.isInteger(seat) || seat < 1) return null; + const rowValue = row.charCodeAt(0) - 64; + return rowValue * 1000 + seat; +} + export default async function randomizeProjects( - secondFloorStart: number = 100 + // I1 is the first table on floor 2. + secondFloorStartLabel: string = 'I' ) { try { const subRes = await getManySubmissions(); @@ -54,7 +75,7 @@ export default async function randomizeProjects( const teams: Team[] = teamRes.body; - const tableNumbers = new Map(); + const tableNumbers = new Map(); for (const team of teams) { if (team._id) tableNumbers.set(team._id, team.tableNumber); } @@ -62,11 +83,17 @@ export default async function randomizeProjects( const updatedSubmissions: object[] = []; const submissionsWithoutTeams: Submission[] = []; + const rowLetter = secondFloorStartLabel.trim().toUpperCase().charAt(0); + const secondFloorStart = (rowLetter.charCodeAt(0) - 64) * 1000; for (const submissions of Object.values(submissionsByJudge)) { const floor = Object.groupBy(submissions, ({ team_id }) => { const tableNumber = tableNumbers.get(team_id); if (!tableNumber) return 'missing'; - return tableNumber < secondFloorStart ? 'first' : 'second'; + + const comparable = toComparableTableNumber(tableNumber); + if (comparable === null) return 'missing'; + + return comparable < secondFloorStart ? 'first' : 'second'; }); const missing = floor.missing; diff --git a/app/(api)/_utils/scoring/rankTeams.ts b/app/(api)/_utils/scoring/rankTeams.ts index 0a4d66ef9..af15573f2 100644 --- a/app/(api)/_utils/scoring/rankTeams.ts +++ b/app/(api)/_utils/scoring/rankTeams.ts @@ -1,34 +1,6 @@ import Submission from '@typeDefs/submission'; import { optedHDTracks, bestHackForSocialGood } from '@data/tracks'; -// interface Team { -// _id?: string; -// teamNumber: number; -// tableNumber: number; -// name: string; -// tracks: string[]; -// active: boolean; -// } - -// export interface TrackScore { -// trackName: string; -// rawScores: {[question: string] : number}; -// finalTrackScore: number | null; -// } - -// export default interface Submission { -// _id?: string; -// judge_id: string; -// team_id: string; -// social_good: number; -// creativity: number; -// presentation: number; -// scores: TrackScore[]; -// comments?: string; -// is_scored: boolean; -// queuePosition: number | null; -// } - function calculateSubmissionScore(submission: Submission) { return submission.scores .map((track_score) => { diff --git a/app/(pages)/(hackers)/_components/HomeJudging/_components/JudgeBanners.tsx b/app/(pages)/(hackers)/_components/HomeJudging/_components/JudgeBanners.tsx index 8435bd413..aee755712 100644 --- a/app/(pages)/(hackers)/_components/HomeJudging/_components/JudgeBanners.tsx +++ b/app/(pages)/(hackers)/_components/HomeJudging/_components/JudgeBanners.tsx @@ -33,7 +33,7 @@ export default function JudgeBanners({ }: JudgeBannersProps) { const { storedValue: tableNumber } = useTableNumberContext(); const { team, judges, loading, error, fetchTeamJudges } = - useTeamJudgesFromTableNumber(tableNumber ?? -1); + useTeamJudgesFromTableNumber(tableNumber ?? ''); const allScored = judges?.every((judge: HydratedJudge) => judge.isScored); useEffect(() => { diff --git a/app/(pages)/(hackers)/_components/TableNumberCheckin/TableNumberCheckin.tsx b/app/(pages)/(hackers)/_components/TableNumberCheckin/TableNumberCheckin.tsx index be0cd5ec1..7e31dcf2c 100644 --- a/app/(pages)/(hackers)/_components/TableNumberCheckin/TableNumberCheckin.tsx +++ b/app/(pages)/(hackers)/_components/TableNumberCheckin/TableNumberCheckin.tsx @@ -133,7 +133,11 @@ export default function TableNumberCheckin() { diff --git a/app/(pages)/_contexts/TableNumberContext.tsx b/app/(pages)/_contexts/TableNumberContext.tsx index 84f4b5077..462449e6b 100644 --- a/app/(pages)/_contexts/TableNumberContext.tsx +++ b/app/(pages)/_contexts/TableNumberContext.tsx @@ -4,8 +4,8 @@ import { createContext } from 'react'; import { useLocalStorage } from '@pages/_hooks/useLocalStorage'; interface TableNumberContextValue { - storedValue: number | null; - setValue: (val: any) => void; + storedValue: string | null; + setValue: (val: string) => void; fetchValue: () => void; loading: boolean; } @@ -14,7 +14,7 @@ export type { TableNumberContextValue }; export const TableNumberContext = createContext({ storedValue: null, - setValue: (_: any) => {}, + setValue: (_: string) => {}, fetchValue: () => {}, loading: false, }); @@ -27,8 +27,21 @@ export default function TableNumberContextProvider({ const { storedValue, setValue, fetchValue, loading } = useLocalStorage('tableNumber'); + const normalizedStoredValue = (() => { + if (storedValue === null) return null; + const normalized = String(storedValue).trim(); + if ( + normalized.length === 0 || + normalized === 'null' || + normalized === 'undefined' + ) { + return null; + } + return normalized; + })(); + const value = { - storedValue: Number(storedValue), + storedValue: normalizedStoredValue, setValue, fetchValue, loading, diff --git a/app/(pages)/_hooks/useTeamJudgesFromTableNumber.ts b/app/(pages)/_hooks/useTeamJudgesFromTableNumber.ts index 7dc4d7d15..199f608bf 100644 --- a/app/(pages)/_hooks/useTeamJudgesFromTableNumber.ts +++ b/app/(pages)/_hooks/useTeamJudgesFromTableNumber.ts @@ -14,13 +14,21 @@ interface HydratedJudge extends User { isScored: boolean; } -export function useTeamJudgesFromTableNumber(tableNumber: number): any { +export function useTeamJudgesFromTableNumber(tableNumber: string): any { const [team, setTeam] = useState(null); const [judges, setJudges] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const fetchTeamJudges = useCallback(async () => { + if (!tableNumber) { + setTeam(null); + setJudges(null); + setLoading(false); + setError(null); + return; + } + try { const teamRes = await getManyTeams({ tableNumber }); diff --git a/app/(pages)/admin/randomize-projects/page.tsx b/app/(pages)/admin/randomize-projects/page.tsx index c3ffed6dc..f1bb9b6f1 100644 --- a/app/(pages)/admin/randomize-projects/page.tsx +++ b/app/(pages)/admin/randomize-projects/page.tsx @@ -18,8 +18,12 @@ export default function Page() { setMissingTeams(null); const formData = new FormData(e.currentTarget); - const secondFloor = parseInt(formData.get('secondFloor') as string); - if (isNaN(secondFloor)) throw new Error('Enter an integer.'); + const secondFloor = String(formData.get('secondFloor') ?? '') + .trim() + .toUpperCase(); + if (!/^[A-L]$/.test(secondFloor)) { + throw new Error('Enter a single row letter from A to L, e.g. I.'); + } const submissionsWithoutTeams = await randomizeProjects(secondFloor); setMissingTeams(JSON.stringify(submissionsWithoutTeams.body, null, 2)); @@ -39,9 +43,9 @@ export default function Page() { style={{ display: 'flex', flexDirection: 'column' }} > - +

The following submissions don't have a team associated with them:

diff --git a/app/_types/parsedRecord.ts b/app/_types/parsedRecord.ts index fb48f55fc..959a40a77 100644 --- a/app/_types/parsedRecord.ts +++ b/app/_types/parsedRecord.ts @@ -1,7 +1,7 @@ interface ParsedRecord { name: string; teamNumber: number; - tableNumber: number; + tableNumber: string; tracks: string[]; active: boolean; } diff --git a/app/_types/team.ts b/app/_types/team.ts index 8ceb34a12..b89ddc7d1 100644 --- a/app/_types/team.ts +++ b/app/_types/team.ts @@ -1,7 +1,7 @@ interface Team { _id?: string; teamNumber: number; - tableNumber: number; + tableNumber: string; name: string; tracks: string[]; reports: { diff --git a/migrations/20260324120000-update-table-number-to-letter-number.mjs b/migrations/20260324120000-update-table-number-to-letter-number.mjs new file mode 100644 index 000000000..00edc72fe --- /dev/null +++ b/migrations/20260324120000-update-table-number-to-letter-number.mjs @@ -0,0 +1,132 @@ +import fs from 'fs'; +import path from 'path'; + +const dataPath = path.resolve( + process.cwd(), + 'app/_data/db_validation_data.json' +); +const data = JSON.parse(fs.readFileSync(dataPath, 'utf8')); +const tracks = [...new Set(data.tracks)]; + +export const up = async (db) => { + await db.command({ + collMod: 'teams', + validator: { + $jsonSchema: { + bsonType: 'object', + title: 'Teams Object Validation', + required: ['teamNumber', 'tableNumber', 'name', 'tracks', 'active'], + properties: { + _id: { + bsonType: 'objectId', + description: '_id must be an ObjectId', + }, + teamNumber: { + bsonType: 'int', + description: 'teamNumber must be an integer', + }, + tableNumber: { + bsonType: 'string', + description: 'tableNumber must be a string', + }, + name: { + bsonType: 'string', + description: 'name must be a string', + }, + tracks: { + bsonType: 'array', + items: { + enum: tracks, + description: 'track must be one of the valid tracks', + }, + description: 'tracks must be an array of strings', + }, + reports: { + bsonType: 'array', + items: { + bsonType: 'object', + required: ['timestamp', 'judge_id'], + properties: { + timestamp: { + bsonType: 'number', + description: 'Timestamp in milliseconds since epoch', + }, + judge_id: { + bsonType: 'string', + description: 'ID of the judge', + }, + }, + }, + }, + active: { + bsonType: 'bool', + description: 'active must be a boolean', + }, + }, + additionalProperties: false, + }, + }, + }); +}; + +export const down = async (db) => { + // Re-introduce tableNumber as an integer if needed. + await db.command({ + collMod: 'teams', + validator: { + $jsonSchema: { + bsonType: 'object', + title: 'Teams Object Validation', + required: ['teamNumber', 'tableNumber', 'name', 'tracks', 'active'], + properties: { + _id: { + bsonType: 'objectId', + description: '_id must be an ObjectId', + }, + teamNumber: { + bsonType: 'int', + description: 'teamNumber must be an integer', + }, + tableNumber: { + bsonType: 'int', + description: 'tableNumber must be an integer', + }, + name: { + bsonType: 'string', + description: 'name must be a string', + }, + tracks: { + bsonType: 'array', + items: { + enum: tracks, + description: 'track must be one of the valid tracks', + }, + description: 'tracks must be an array of strings', + }, + reports: { + bsonType: 'array', + items: { + bsonType: 'object', + required: ['timestamp', 'judge_id'], + properties: { + timestamp: { + bsonType: 'number', + description: 'Timestamp in milliseconds since epoch', + }, + judge_id: { + bsonType: 'string', + description: 'ID of the judge', + }, + }, + }, + }, + active: { + bsonType: 'bool', + description: 'active must be a boolean', + }, + }, + additionalProperties: false, + }, + }, + }); +};