From 50debe1a2eacfb9d2dd0395ec9bff132539b8bc9 Mon Sep 17 00:00:00 2001 From: reehals Date: Thu, 8 Jan 2026 16:28:35 -0800 Subject: [PATCH 1/2] CSV ingestion test --- .prettierignore | 1 + __tests__/csvAlgorithm.test.ts | 48 ++ __tests__/csvValidation.test.ts | 32 ++ .../_actions/logic/checkTeamsPopulated.ts | 14 + app/(api)/_actions/logic/ingestTeams.ts | 7 + app/(api)/_actions/logic/validateCSV.ts | 28 + .../_utils/csv-ingestion/csvAlgorithm.ts | 543 +++++++++++++++--- app/(pages)/admin/csv/page.tsx | 332 ++++++++++- migrations/20250420035636-update-teams.mjs | 92 ++- ...0260105090000-remove-team-tracks-limit.mjs | 133 +++++ migrations/create-teams.mjs | 1 - 11 files changed, 1105 insertions(+), 126 deletions(-) create mode 100644 .prettierignore create mode 100644 __tests__/csvAlgorithm.test.ts create mode 100644 __tests__/csvValidation.test.ts create mode 100644 app/(api)/_actions/logic/checkTeamsPopulated.ts create mode 100644 app/(api)/_actions/logic/ingestTeams.ts create mode 100644 app/(api)/_actions/logic/validateCSV.ts create mode 100644 migrations/20260105090000-remove-team-tracks-limit.mjs diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..b41bcf98 --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +# **.mjs \ No newline at end of file diff --git a/__tests__/csvAlgorithm.test.ts b/__tests__/csvAlgorithm.test.ts new file mode 100644 index 00000000..4ee41f6b --- /dev/null +++ b/__tests__/csvAlgorithm.test.ts @@ -0,0 +1,48 @@ +import { + matchCanonicalTrack, + sortTracks, +} from "@utils/csv-ingestion/csvAlgorithm"; + +describe("csvAlgorithm track matching", () => { + it("matches tracks case-insensitively to canonical names", () => { + expect(matchCanonicalTrack("best hardware hack")).toBe( + "Best Hardware Hack" + ); + expect(matchCanonicalTrack("Best hardware hack")).toBe( + "Best Hardware Hack" + ); + }); + + it("does not attempt to correct spelling", () => { + expect(matchCanonicalTrack("Best Hardwre Hack")).toBeNull(); + expect(matchCanonicalTrack("Best Assistive Technlogy")).toBeNull(); + }); + + it("ingests all opt-in tracks and does not cap length", () => { + const tracks = sortTracks( + "best hardware hack", + "", + "", + "Best Use of Gemini API; Best Use of MongoDB Atlas, Best Use of Vectara | Best Use of Auth0" + ); + + expect(tracks).toEqual([ + "Best Hardware Hack", + "Best Use of Gemini API", + "Best Use of MongoDB Atlas", + "Best Use of Vectara", + "Best Use of Auth0", + ]); + }); + + it("filters out excluded tracks", () => { + const tracks = sortTracks( + "Best Hack for Social Good", + "Hacker's Choice Award", + "", + "Best Hack for Social Good, Best Hardware Hack" + ); + + expect(tracks).toEqual(["Best Hardware Hack"]); + }); +}); diff --git a/__tests__/csvValidation.test.ts b/__tests__/csvValidation.test.ts new file mode 100644 index 00000000..91a8e2c5 --- /dev/null +++ b/__tests__/csvValidation.test.ts @@ -0,0 +1,32 @@ +import { validateCsvBlob } from "@utils/csv-ingestion/csvAlgorithm"; + +describe("csvAlgorithm validation", () => { + it("silently ignores 'N/A' without warnings", async () => { + const csv = + "Table Number,Project Status,Project Title,Track #1 (Primary Track),Track #2,Track #3,Opt-In Prizes\n" + + "12,Submitted (Gallery/Visible),Test Project,Best Beginner Hack,N/A,,\n"; + + const blob = new Blob([csv], { type: "text/csv" }); + const res = await validateCsvBlob(blob); + + expect(res.ok).toBe(true); + expect(res.report.errorRows).toBe(0); + expect(res.report.warningRows).toBe(0); + expect(res.report.issues).toEqual([]); + }); + + it("treats duplicate tracks as warnings (non-blocking)", async () => { + const csv = + "Table Number,Project Status,Project Title,Track #1 (Primary Track),Track #2,Track #3,Opt-In Prizes\n" + + "87,Submitted (Gallery/Visible),PartyPal,Best UI/UX Design,Best UI/UX Design,,\n"; + + const blob = new Blob([csv], { type: "text/csv" }); + const res = await validateCsvBlob(blob); + + expect(res.ok).toBe(true); + expect(res.report.errorRows).toBe(0); + expect(res.report.warningRows).toBe(1); + expect(res.report.issues[0].severity).toBe("warning"); + expect(res.report.issues[0].duplicateTracks).toEqual(["Best UI/UX Design"]); + }); +}); diff --git a/app/(api)/_actions/logic/checkTeamsPopulated.ts b/app/(api)/_actions/logic/checkTeamsPopulated.ts new file mode 100644 index 00000000..c3cef818 --- /dev/null +++ b/app/(api)/_actions/logic/checkTeamsPopulated.ts @@ -0,0 +1,14 @@ +"use server"; + +import { getDatabase } from "@utils/mongodb/mongoClient.mjs"; + +export default async function checkTeamsPopulated() { + try { + const db = await getDatabase(); + const count = await db.collection("teams").countDocuments({}); + return { ok: true, populated: count > 0, count, error: null }; + } catch (e) { + const error = e as Error; + return { ok: false, populated: false, count: 0, error: error.message }; + } +} diff --git a/app/(api)/_actions/logic/ingestTeams.ts b/app/(api)/_actions/logic/ingestTeams.ts new file mode 100644 index 00000000..043bfe19 --- /dev/null +++ b/app/(api)/_actions/logic/ingestTeams.ts @@ -0,0 +1,7 @@ +"use server"; + +import { CreateManyTeams } from "@datalib/teams/createTeams"; + +export default async function ingestTeams(body: object) { + return CreateManyTeams(body); +} diff --git a/app/(api)/_actions/logic/validateCSV.ts b/app/(api)/_actions/logic/validateCSV.ts new file mode 100644 index 00000000..f6061ce6 --- /dev/null +++ b/app/(api)/_actions/logic/validateCSV.ts @@ -0,0 +1,28 @@ +"use server"; + +import { validateCsvBlob } from "@utils/csv-ingestion/csvAlgorithm"; + +export default async function validateCSV(formData: FormData) { + const file = formData.get("file") as File | null; + if (!file) { + return { + ok: false, + body: null, + validBody: null, + report: null, + error: "Missing file", + }; + } + + const data = await file.arrayBuffer(); + const blob = new Blob([data], { type: file.type }); + + const res = await validateCsvBlob(blob); + return { + ok: res.ok, + body: res.body, + validBody: res.validBody, + report: res.report, + error: res.error, + }; +} diff --git a/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts b/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts index 403041df..337f2600 100644 --- a/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts +++ b/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts @@ -1,99 +1,457 @@ -import csv from 'csv-parser'; -import trackData from '@data/db_validation_data.json' assert { type: 'json' }; -import { Readable } from 'stream'; -import ParsedRecord from '@typeDefs/parsedRecord'; +import csv from "csv-parser"; +import trackData from "@data/db_validation_data.json" assert { type: "json" }; +import { Readable } from "stream"; +import ParsedRecord from "@typeDefs/parsedRecord"; const filteredTracks = [ - 'Best Hack for Social Good', + "Best Hack for Social Good", "Hacker's Choice Award", - 'N/A', + "N/A", ]; -const validTracks: string[] = trackData.tracks.filter( - (t) => !filteredTracks.includes(t) + +export type CsvTrackAutoFix = { + raw: string; + canonical: string; +}; + +export type CsvRowIssue = { + rowIndex: number; // 1-based, counting CSV rows processed + teamNumberRaw?: string; + teamNumber?: number; + projectTitle?: string; + contactEmails: string[]; + contactNames: string[]; + memberEmails: string[]; + memberNames: string[]; + severity: "error" | "warning"; + invalidTracks: string[]; + excludedTracks: string[]; + duplicateTracks: string[]; + autoFixedTracks: CsvTrackAutoFix[]; + missingFields: string[]; + memberColumnsFromTeamMember1: Array<{ header: string; value: string }>; +}; + +export type CsvValidationReport = { + totalTeamsParsed: number; + validTeams: number; + errorRows: number; + warningRows: number; + unknownTracks: string[]; + issues: CsvRowIssue[]; +}; + +type TrackMatchCandidate = { + canonical: string; + normalized: string; +}; + +function normalizeTrackName(value: string): string { + // Only do trimming and case-insensitive matching. + return value.trim().toLowerCase(); +} + +const filteredTrackSet = new Set(filteredTracks.map(normalizeTrackName)); + +const validTracks: string[] = (trackData.tracks as string[]).filter( + (t) => !filteredTrackSet.has(normalizeTrackName(t)) ); -function sortTracks( +const trackCandidates: TrackMatchCandidate[] = validTracks.map((canonical) => ({ + canonical, + normalized: normalizeTrackName(canonical), +})); + +const normalizedToCanonical = new Map(); +for (const candidate of trackCandidates) { + if (!normalizedToCanonical.has(candidate.normalized)) { + normalizedToCanonical.set(candidate.normalized, candidate.canonical); + } +} + +export function matchCanonicalTrack(raw: string): string | null { + const normalized = normalizeTrackName(raw); + if (!normalized) return null; + if (filteredTrackSet.has(normalized)) return null; + + const exact = normalizedToCanonical.get(normalized); + if (exact) return exact; + + return null; +} + +function splitOptInTracks(value: string): string[] { + // CSV exports vary; tolerate commas/semicolons/pipes/newlines. + return value + .split(/[,;|\n\r]+/g) + .map((t) => t.trim()) + .filter(Boolean); +} + +function isSubmittedNonDraft(status: unknown): boolean { + const s = String(status ?? "") + .trim() + .toLowerCase(); + if (!s) return false; + if (s.includes("draft")) return false; + return s.includes("submitted"); +} + +function extractContactInfoFromRow(data: Record): { + contactEmails: string[]; + contactNames: string[]; + memberEmails: string[]; + memberNames: string[]; +} { + const contactEmails = new Set(); + const contactNames = new Set(); + const memberEmails = new Set(); + const memberNames = new Set(); + + const looksLikeUrl = (value: string) => /^https?:\/\//i.test(value); + + for (const [key, value] of Object.entries(data)) { + const k = key.toLowerCase(); + const v = String(value ?? "").trim(); + if (!v) continue; + + const isTrackColumn = + k.includes("track") || k.includes("opt-in") || k.includes("prize"); + const isProjectTitle = k.includes("project title"); + const isContactish = + k.includes("contact") || k.includes("submitter") || k.includes("owner"); + + if (k.includes("email") || k.includes("e-mail")) { + v.split(/[\s,;|]+/g) + .map((s) => s.trim()) + .filter(Boolean) + .filter((s) => s.includes("@")) + .forEach((email) => { + memberEmails.add(email); + if (isContactish) contactEmails.add(email); + }); + continue; + } + + // Names + const isNameColumn = k.includes("name"); + const isMemberishColumn = + k.includes("member") || + k.includes("teammate") || + k.includes("team member") || + k.includes("participant"); + const isProbablyNotAName = + k.includes("school") || + k.includes("major") || + k.includes("diet") || + k.includes("shirt") || + k.includes("pronoun") || + k.includes("role") || + k.includes("github") || + k.includes("linkedin") || + k.includes("devpost") || + k.includes("portfolio") || + k.includes("phone"); + + if (!isTrackColumn && !isProjectTitle && !looksLikeUrl(v)) { + if (isNameColumn) { + memberNames.add(v); + if (isContactish) contactNames.add(v); + } else if (isMemberishColumn && !isProbablyNotAName) { + memberNames.add(v); + } + } + } + + return { + contactEmails: Array.from(contactEmails).sort(), + contactNames: Array.from(contactNames).sort(), + memberEmails: Array.from(memberEmails).sort(), + memberNames: Array.from(memberNames).sort(), + }; +} + +function canonicalHeaderKey(value: string): string { + return String(value ?? "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]/g, ""); +} + +function extractMemberColumnsFromTeamMember1( + data: Record, + headers: string[] | null, + startIndex: number +): Array<{ header: string; value: string }> { + if (!headers || startIndex < 0 || startIndex >= headers.length) return []; + const rows: Array<{ header: string; value: string }> = []; + for (let i = startIndex; i < headers.length; i += 1) { + const header = headers[i]; + rows.push({ header, value: String(data[header] ?? "").trim() }); + } + return rows; +} + +function validateAndCanonicalizeTracks(rawTracks: string[]): { + canonicalTracks: string[]; + invalidTracks: string[]; + excludedTracks: string[]; + duplicateTracks: string[]; + autoFixedTracks: CsvTrackAutoFix[]; +} { + const canonicalTracks: string[] = []; + const invalidTracks: string[] = []; + const excludedTracks: string[] = []; + const duplicateTracks: string[] = []; + const autoFixedTracks: CsvTrackAutoFix[] = []; + + const seen = new Set(); + const excludedSet = new Set(filteredTracks.map((t) => normalizeTrackName(t))); + const silentlyIgnoredSet = new Set(["n/a"]); + + for (const raw of rawTracks) { + const trimmed = String(raw ?? "").trim(); + if (!trimmed) continue; + + const normalized = normalizeTrackName(trimmed); + if (silentlyIgnoredSet.has(normalized)) continue; + + if (excludedSet.has(normalized)) { + excludedTracks.push(trimmed); + continue; + } + + const canonical = matchCanonicalTrack(trimmed); + if (!canonical) { + invalidTracks.push(trimmed); + continue; + } + + if (seen.has(canonical)) { + duplicateTracks.push(canonical); + continue; + } + + if (trimmed !== canonical) { + autoFixedTracks.push({ raw: trimmed, canonical }); + } + + seen.add(canonical); + canonicalTracks.push(canonical); + } + + return { + canonicalTracks, + invalidTracks, + excludedTracks, + duplicateTracks, + autoFixedTracks, + }; +} + +function validateTracksFromColumns( + track1: string, + track2: string, + track3: string, + optIns: string +): { + canonicalTracks: string[]; + invalidTracks: string[]; + excludedTracks: string[]; + duplicateTracks: string[]; + autoFixedTracks: CsvTrackAutoFix[]; +} { + const primaryRaw = [track1, track2, track3]; + const optInRaw = splitOptInTracks(optIns); + + // First pass: validate primary tracks. + const primary = validateAndCanonicalizeTracks(primaryRaw); + + // Second pass: validate opt-ins, but *ignore* entries that merely repeat a primary track. + const optIn = validateAndCanonicalizeTracks(optInRaw); + + const primarySet = new Set(primary.canonicalTracks); + const mergedTracks: string[] = [...primary.canonicalTracks]; + const mergedSeen = new Set(mergedTracks); + + for (const t of optIn.canonicalTracks) { + if (mergedSeen.has(t)) continue; + mergedTracks.push(t); + mergedSeen.add(t); + } + + // Duplicates: + // - keep duplicates inside primary columns + // - keep duplicates inside opt-in list + // - DO NOT report duplicates that are just opt-in repeating a primary track + const optInDuplicatesNotInPrimary = optIn.duplicateTracks.filter( + (t) => !primarySet.has(t) + ); + + return { + canonicalTracks: mergedTracks, + invalidTracks: [...primary.invalidTracks, ...optIn.invalidTracks], + excludedTracks: [...primary.excludedTracks, ...optIn.excludedTracks], + duplicateTracks: [ + ...primary.duplicateTracks, + ...optInDuplicatesNotInPrimary, + ], + autoFixedTracks: [...primary.autoFixedTracks, ...optIn.autoFixedTracks], + }; +} + +export function sortTracks( track1: string, track2: string, track3: string, chosentracks: string ): string[] { - const initialTracks = [track1, track2, track3] - .map((t) => t.trim()) - .filter( - (t) => - validTracks.includes(t) && - t !== 'Best Hack for Social Good' && - t !== "Hacker's Choice Award" - ); // explicitly filter it out again - - const existingTrackSet = new Set(initialTracks); - - if (chosentracks.length > 1) { - chosentracks - .split(',') - .map((t) => t.trim()) - .forEach((track) => { - if ( - validTracks.includes(track) && - !existingTrackSet.has(track) && - track !== 'Best Hack for Social Good' // explicitly filter it out - ) { - initialTracks.push(track); - existingTrackSet.add(track); - } - }); - } + const ordered: string[] = []; + const seen = new Set(); - if (initialTracks.length > 4) { - initialTracks.length = 4; - } + const maybeAdd = (raw: string) => { + const canonical = matchCanonicalTrack(raw); + if (!canonical) return; + if (seen.has(canonical)) return; + ordered.push(canonical); + seen.add(canonical); + }; - const tracksSet = Array.from(new Set(initialTracks)); + [track1, track2, track3].forEach(maybeAdd); - return tracksSet; + if (chosentracks && chosentracks.trim().length > 0) { + for (const optIn of splitOptInTracks(chosentracks)) { + maybeAdd(optIn); + } + } + + return ordered; } -export default async function csvAlgorithm( - blob: Blob -): Promise<{ ok: boolean; body: ParsedRecord[] | null; error: string | null }> { +export async function validateCsvBlob(blob: Blob): Promise<{ + ok: boolean; + body: ParsedRecord[] | null; + validBody: ParsedRecord[] | null; + report: CsvValidationReport; + error: string | null; +}> { + const issues: CsvRowIssue[] = []; + const unknownTrackSet = new Set(); + const output: ParsedRecord[] = []; + try { - const parsePromise = new Promise((resolve, reject) => { - const output: ParsedRecord[] = []; + const results = await new Promise((resolve, reject) => { + let rowIndex = 0; + let headers: string[] | null = null; + let teamMember1StartIndex = -1; const parseBlob = async () => { const buffer = Buffer.from(await blob.arrayBuffer()); const stream = Readable.from(buffer.toString()); - // let i = 0; + stream .pipe(csv()) - .on('data', (data) => { + .on("headers", (h: string[]) => { + headers = h; + const target = canonicalHeaderKey("Team member 1 first name"); + teamMember1StartIndex = h.findIndex( + (header) => canonicalHeaderKey(header) === target + ); + }) + .on("data", (data) => { + rowIndex += 1; + if ( - data['Table Number'] !== '' && - data['Project Status'] === 'Submitted (Gallery/Visible)' + data["Table Number"] !== "" && + isSubmittedNonDraft(data["Project Status"]) ) { - const track1 = data['Track #1 (Primary Track)'] ?? ''; - const track2 = data['Track #2'] ?? ''; - const track3 = data['Track #3'] ?? ''; - const optIns = data['Opt-In Prizes'] ?? ''; + const projectTitle = data["Project Title"]; + const tableNumberRaw = data["Table Number"]; + const parsedTeamNumber = parseInt(tableNumberRaw); + + const { contactEmails, contactNames, memberEmails, memberNames } = + extractContactInfoFromRow(data); + + const memberColumnsFromTeamMember1 = + extractMemberColumnsFromTeamMember1( + data, + headers, + teamMember1StartIndex + ); - const tracksInOrder = sortTracks(track1, track2, track3, optIns); + const track1 = data["Track #1 (Primary Track)"] ?? ""; + const track2 = data["Track #2"] ?? ""; + const track3 = data["Track #3"] ?? ""; + const optIns = data["Opt-In Prizes"] ?? ""; + + const { + canonicalTracks, + invalidTracks, + excludedTracks, + duplicateTracks, + autoFixedTracks, + } = validateTracksFromColumns(track1, track2, track3, optIns); + + invalidTracks.forEach((t) => unknownTrackSet.add(t)); + + const missingFields: string[] = []; + if (!projectTitle || String(projectTitle).trim().length === 0) { + missingFields.push("Project Title"); + } + if (!Number.isFinite(parsedTeamNumber)) { + missingFields.push("Table Number"); + } + if (canonicalTracks.length === 0) { + missingFields.push("Tracks"); + } + + if ( + invalidTracks.length > 0 || + missingFields.length > 0 || + excludedTracks.length > 0 || + duplicateTracks.length > 0 || + autoFixedTracks.length > 0 + ) { + const severity = + invalidTracks.length > 0 || missingFields.length > 0 + ? "error" + : "warning"; + issues.push({ + rowIndex, + teamNumberRaw: tableNumberRaw, + teamNumber: Number.isFinite(parsedTeamNumber) + ? parsedTeamNumber + : undefined, + projectTitle, + contactEmails, + contactNames, + memberEmails, + memberNames, + severity, + invalidTracks, + excludedTracks, + duplicateTracks, + autoFixedTracks, + missingFields, + memberColumnsFromTeamMember1, + }); + } output.push({ - name: data['Project Title'], - teamNumber: parseInt(data['Table Number']), - tableNumber: 0, // doing it later (on end) - tracks: tracksInOrder, + name: projectTitle, + teamNumber: parsedTeamNumber, + tableNumber: 0, // assigned after ordering + tracks: canonicalTracks, active: true, }); } }) - .on('end', () => { + .on("end", () => { const bestHardwareTeams = output.filter((team) => - team.tracks.includes('Best Hardware Hack') + team.tracks.includes("Best Hardware Hack") ); const otherTeams = output.filter( - (team) => !team.tracks.includes('Best Hardware Hack') + (team) => !team.tracks.includes("Best Hardware Hack") ); const orderedTeams = [...bestHardwareTeams, ...otherTeams]; @@ -104,14 +462,71 @@ export default async function csvAlgorithm( resolve(orderedTeams); }) - .on('error', (error) => reject(error)); + .on("error", (error) => reject(error)); }; + parseBlob().catch(reject); }); - const results: ParsedRecord[] = await parsePromise; + const errorRows = issues.filter((i) => i.severity === "error").length; + const warningRows = issues.filter((i) => i.severity === "warning").length; + + const errorTeamNumbers = new Set( + issues + .filter((i) => i.severity === "error" && i.teamNumber !== undefined) + .map((i) => i.teamNumber as number) + ); + const validBody = results.filter( + (t) => !errorTeamNumbers.has(t.teamNumber) + ); + + const report: CsvValidationReport = { + totalTeamsParsed: results.length, + validTeams: validBody.length, + errorRows, + warningRows, + unknownTracks: Array.from(unknownTrackSet).sort(), + issues, + }; + + const ok = report.errorRows === 0; + return { + ok, + body: results, + validBody, + report, + error: ok ? null : "CSV validation failed. Fix errors and re-validate.", + }; + } catch (e) { + const error = e as Error; + const report: CsvValidationReport = { + totalTeamsParsed: 0, + validTeams: 0, + errorRows: 0, + warningRows: 0, + unknownTracks: [], + issues: [], + }; + return { + ok: false, + body: null, + validBody: null, + report, + error: error.message, + }; + } +} + +export default async function csvAlgorithm( + blob: Blob +): Promise<{ ok: boolean; body: ParsedRecord[] | null; error: string | null }> { + try { + const validated = await validateCsvBlob(blob); + if (!validated.ok) { + return { ok: false, body: null, error: validated.error }; + } - return { ok: true, body: results, error: null }; + return { ok: true, body: validated.body, error: null }; } catch (e) { const error = e as Error; return { ok: false, body: null, error: error.message }; diff --git a/app/(pages)/admin/csv/page.tsx b/app/(pages)/admin/csv/page.tsx index 59266e7a..832db1d7 100644 --- a/app/(pages)/admin/csv/page.tsx +++ b/app/(pages)/admin/csv/page.tsx @@ -1,29 +1,333 @@ -'use client'; -import ingestCSV from '@actions/logic/ingestCSV'; -import React, { useState } from 'react'; +"use client"; +import validateCSV from "@actions/logic/validateCSV"; +import ingestTeams from "@actions/logic/ingestTeams"; +import checkTeamsPopulated from "@actions/logic/checkTeamsPopulated"; +import React, { useEffect, useState } from "react"; + +type ValidationResponse = { + ok: boolean; + body: any; + validBody: any; + report: any; + error: string | null; +}; export default function CsvIngestion() { const [pending, setPending] = useState(false); - const [response, setResponse] = useState(''); + const [validating, setValidating] = useState(false); + const [response, setResponse] = useState(""); + const [validation, setValidation] = useState(null); + const [file, setFile] = useState(null); + + const [teamsAlreadyPopulated, setTeamsAlreadyPopulated] = useState<{ + populated: boolean; + count: number; + } | null>(null); + + useEffect(() => { + let alive = true; + (async () => { + try { + const res = (await checkTeamsPopulated()) as { + populated: boolean; + count: number; + }; + if (alive) setTeamsAlreadyPopulated(res); + } catch { + // non-blocking + } + })(); + return () => { + alive = false; + }; + }, []); + + const validateHandler = async () => { + if (!file) { + setResponse("Please choose a CSV file first."); + return; + } + setValidating(true); + const formData = new FormData(); + formData.append("file", file); + + const res = (await validateCSV(formData)) as ValidationResponse; + setValidation(res); + setResponse(""); + setValidating(false); + }; + + const uploadValidHandler = async () => { + if (!validation?.validBody) return; + setPending(true); + const res = await ingestTeams(validation.validBody); + setResponse(JSON.stringify(res, null, 2)); + setPending(false); + }; + + const uploadAllHandler = async () => { + if (!validation?.body) return; + + const errors = validation.report?.errorRows ?? 0; + if (errors > 0) { + const ok = window.confirm( + `There are ${errors} error rows. Force upload ALL teams anyway?` + ); + if (!ok) return; + } - const handler = async (event: React.FormEvent) => { - event.preventDefault(); setPending(true); - const formData = new FormData(event.currentTarget); - const res = await ingestCSV(formData); - setResponse(JSON.stringify(res)); + const res = await ingestTeams(validation.body); + setResponse(JSON.stringify(res, null, 2)); setPending(false); }; + const canonKey = (value: string) => + String(value ?? "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]/g, ""); + + const buildTeamMemberLines = (issue: any): string[] => { + const cols = Array.isArray(issue?.memberColumnsFromTeamMember1) + ? issue.memberColumnsFromTeamMember1 + : []; + + const findByHeaderPrefix = (prefix: string): string => { + const p = canonKey(prefix); + for (const c of cols) { + const header = String(c?.header ?? ""); + const value = String(c?.value ?? "").trim(); + if (!header) continue; + const hk = canonKey(header); + if (hk.startsWith(p)) return value; + } + return ""; + }; + + const lines: string[] = []; + for (let n = 1; n <= 4; n += 1) { + const first = findByHeaderPrefix(`Team member ${n} first name`); + const last = findByHeaderPrefix(`Team member ${n} last name`); + const email = + findByHeaderPrefix(`Team member ${n} email`) || + findByHeaderPrefix(`Team member ${n} e-mail`); + + const fullName = `${first} ${last}`.trim().replace(/\s+/g, " "); + if (!fullName && !email) continue; + + const namePart = fullName || "(no name)"; + const emailPart = email ? ` — ${email}` : ""; + lines.push(`${namePart}${emailPart}`); + } + + return lines; + }; + + const buildCopyText = (severity: "error" | "warning") => { + if (!validation?.report) return ""; + + const rows = validation.report.issues + .filter((i: any) => i.severity === severity) + .map((i: any) => { + const header = `Team ${i.teamNumberRaw} — ${i.projectTitle}`; + + const submitterName = i.contactNames?.length + ? `Submitter: ${i.contactNames.join(", ")}` + : ""; + const submitterEmail = i.contactEmails?.length + ? `Submitter Email: ${i.contactEmails.join(", ")}` + : ""; + + const memberLines = buildTeamMemberLines(i); + const membersBlock = memberLines.length + ? ["Members:", ...memberLines.map((l) => ` ${l}`)].join("\n") + : ""; + + return [header, submitterName, submitterEmail, membersBlock] + .filter(Boolean) + .join("\n"); + }); + + return rows.join("\n\n"); + }; + + const copyToClipboard = async (text: string) => { + if (!text) return; + try { + await navigator.clipboard.writeText(text); + setResponse("Copied to clipboard."); + } catch { + setResponse(text); + } + }; + return (
+ {teamsAlreadyPopulated?.populated ? ( +
+
Teams database already populated
+
+ Found {teamsAlreadyPopulated.count} existing team records. Uploading + again may create duplicates. +
+
+ ) : null}

Upload CSV:

-
- - -
-

{pending ? 'parsing CSV and creating teams...' : response}

+

+ Step 1: Validate the CSV and review issues. Step 2: Upload to insert + teams. +

+ +
+ { + const next = e.target.files?.[0] ?? null; + setFile(next); + setValidation(null); + setResponse(""); + }} + /> + + + + +
+ + {validation?.report && ( +
+

Validation Results

+

+ Parsed: {validation.report.totalTeamsParsed} teams. Valid:{" "} + {validation.report.validTeams}. Errors:{" "} + {validation.report.errorRows}. Warnings:{" "} + {validation.report.warningRows}. +

+ + {validation.report.errorRows > 0 && ( +
+

Errors

+ +
    + {validation.report.issues + .filter((i: any) => i.severity === "error") + .map((i: any) => ( +
  • + Team {i.teamNumberRaw} — {i.projectTitle} + {i.contactNames?.length ? ( + <> (Submitter: {i.contactNames.join(", ")}) + ) : null} + {i.missingFields?.length ? ( + <> (Missing: {i.missingFields.join(", ")}) + ) : null} + {i.invalidTracks?.length ? ( + <> (Invalid tracks: {i.invalidTracks.join(", ")}) + ) : null} + {buildTeamMemberLines(i).length ? ( +
    +                            {buildTeamMemberLines(i)
    +                              .map((l) => `Member: ${l}`)
    +                              .join("\n")}
    +                          
    + ) : null} +
  • + ))} +
+
+ )} + + {validation.report.warningRows > 0 && ( +
+

Warnings

+ +
    + {validation.report.issues + .filter((i: any) => i.severity === "warning") + .map((i: any) => ( +
  • + Team {i.teamNumberRaw} — {i.projectTitle} + {i.contactNames?.length ? ( + <> (Submitter: {i.contactNames.join(", ")}) + ) : null} + {i.duplicateTracks?.length ? ( + <> (Duplicates: {i.duplicateTracks.join(", ")}) + ) : null} + {i.excludedTracks?.length ? ( + <> (Excluded: {i.excludedTracks.join(", ")}) + ) : null} + {i.autoFixedTracks?.length ? ( + <> (Auto-fixed casing/spacing) + ) : null} + {buildTeamMemberLines(i).length ? ( +
    +                            {buildTeamMemberLines(i)
    +                              .map((l) => `Member: ${l}`)
    +                              .join("\n")}
    +                          
    + ) : null} +
  • + ))} +
+
+ )} + +
+ + Raw report (JSON) + +
+                {JSON.stringify(validation.report, null, 2)}
+              
+
+
+ )} + + {validation?.report && ( +
+ + + +
+ )} + +
+          {pending ? "parsing CSV and creating teams..." : response}
+        
); diff --git a/migrations/20250420035636-update-teams.mjs b/migrations/20250420035636-update-teams.mjs index 906b3f66..8f50b4ff 100644 --- a/migrations/20250420035636-update-teams.mjs +++ b/migrations/20250420035636-update-teams.mjs @@ -1,67 +1,66 @@ -import fs from 'fs'; -import path from 'path'; +import fs from "fs"; +import path from "path"; const dataPath = path.resolve( process.cwd(), - 'app/_data/db_validation_data.json' + "app/_data/db_validation_data.json" ); -const data = JSON.parse(fs.readFileSync(dataPath, 'utf8')); +const data = JSON.parse(fs.readFileSync(dataPath, "utf8")); const tracks = [...new Set(data.tracks)]; export const up = async (db) => { await db.command({ - collMod: 'teams', + collMod: "teams", validator: { $jsonSchema: { - bsonType: 'object', - title: 'Teams Object Validation', - required: ['teamNumber', 'tableNumber', 'name', 'tracks', 'active'], + bsonType: "object", + title: "Teams Object Validation", + required: ["teamNumber", "tableNumber", "name", "tracks", "active"], properties: { _id: { - bsonType: 'objectId', - description: '_id must be an ObjectId', + bsonType: "objectId", + description: "_id must be an ObjectId", }, teamNumber: { - bsonType: 'int', - description: 'teamNumber must be an integer', + bsonType: "int", + description: "teamNumber must be an integer", }, tableNumber: { - bsonType: 'int', - description: 'tableNumber must be an integer', + bsonType: "int", + description: "tableNumber must be an integer", }, name: { - bsonType: 'string', - description: 'name must be a string', + bsonType: "string", + description: "name must be a string", }, tracks: { - bsonType: 'array', - maxItems: 6, + bsonType: "array", items: { enum: tracks, - description: 'track must be one of the valid tracks', + description: "track must be one of the valid tracks", }, - description: 'tracks must be an array of strings', + description: "tracks must be an array of strings", }, reports: { - bsonType: 'array', + bsonType: "array", items: { - bsonType: 'object', - required: ['timestamp', 'judge_id'], + bsonType: "object", + required: ["timestamp", "judge_id"], properties: { timestamp: { - bsonType: 'number', - description: 'Timestamp in milliseconds since epoch', + bsonType: "number", + description: "Timestamp in milliseconds since epoch", }, judge_id: { - bsonType: 'string', - description: 'ID of the judge', + bsonType: "string", + description: "ID of the judge", }, }, }, }, active: { - bsonType: 'bool', - description: 'active must be a boolean', + bsonType: "bool", + description: "active must be a boolean", }, }, additionalProperties: false, @@ -72,41 +71,40 @@ export const up = async (db) => { export const down = async (db) => { await db.command({ - collMod: 'teams', + collMod: "teams", validator: { $jsonSchema: { - bsonType: 'object', - title: 'Teams Object Validation', - required: ['teamNumber', 'tableNumber', 'name', 'tracks', 'active'], + bsonType: "object", + title: "Teams Object Validation", + required: ["teamNumber", "tableNumber", "name", "tracks", "active"], properties: { _id: { - bsonType: 'objectId', - description: '_id must be an ObjectId', + bsonType: "objectId", + description: "_id must be an ObjectId", }, teamNumber: { - bsonType: 'int', - description: 'teamNumber must be an integer', + bsonType: "int", + description: "teamNumber must be an integer", }, tableNumber: { - bsonType: 'int', - description: 'tableNumber must be an integer', + bsonType: "int", + description: "tableNumber must be an integer", }, name: { - bsonType: 'string', - description: 'name must be a string', + bsonType: "string", + description: "name must be a string", }, tracks: { - bsonType: 'array', - maxItems: 6, + bsonType: "array", items: { enum: tracks, - description: 'track must be one of the valid tracks', + description: "track must be one of the valid tracks", }, - description: 'tracks must be an array of strings', + description: "tracks must be an array of strings", }, active: { - bsonType: 'bool', - description: 'active must be a boolean', + bsonType: "bool", + description: "active must be a boolean", }, }, additionalProperties: false, diff --git a/migrations/20260105090000-remove-team-tracks-limit.mjs b/migrations/20260105090000-remove-team-tracks-limit.mjs new file mode 100644 index 00000000..da017981 --- /dev/null +++ b/migrations/20260105090000-remove-team-tracks-limit.mjs @@ -0,0 +1,133 @@ +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: '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, + }, + }, + }); +}; + +export const down = async (db) => { + // Re-introduce the previous 6-track cap 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', + maxItems: 6, + 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, + }, + }, + }); +}; diff --git a/migrations/create-teams.mjs b/migrations/create-teams.mjs index 4929a4bb..330eb2ec 100644 --- a/migrations/create-teams.mjs +++ b/migrations/create-teams.mjs @@ -34,7 +34,6 @@ export async function up(db) { }, tracks: { bsonType: 'array', - maxItems: 6, items: { enum: tracks, description: 'track must be one of the valid tracks', From 5427484e24854286f02b957db02df399b0c965a4 Mon Sep 17 00:00:00 2001 From: reehals Date: Wed, 14 Jan 2026 12:50:47 -0800 Subject: [PATCH 2/2] Linter fixes --- __tests__/csvAlgorithm.test.ts | 50 +++---- __tests__/csvValidation.test.ts | 22 ++-- .../_actions/logic/checkTeamsPopulated.ts | 6 +- app/(api)/_actions/logic/ingestTeams.ts | 4 +- app/(api)/_actions/logic/validateCSV.ts | 8 +- .../_utils/csv-ingestion/csvAlgorithm.ts | 122 +++++++++--------- app/(pages)/admin/csv/page.tsx | 96 +++++++------- 7 files changed, 154 insertions(+), 154 deletions(-) diff --git a/__tests__/csvAlgorithm.test.ts b/__tests__/csvAlgorithm.test.ts index 4ee41f6b..4b304bc5 100644 --- a/__tests__/csvAlgorithm.test.ts +++ b/__tests__/csvAlgorithm.test.ts @@ -1,48 +1,48 @@ import { matchCanonicalTrack, sortTracks, -} from "@utils/csv-ingestion/csvAlgorithm"; +} from '@utils/csv-ingestion/csvAlgorithm'; -describe("csvAlgorithm track matching", () => { - it("matches tracks case-insensitively to canonical names", () => { - expect(matchCanonicalTrack("best hardware hack")).toBe( - "Best Hardware Hack" +describe('csvAlgorithm track matching', () => { + it('matches tracks case-insensitively to canonical names', () => { + expect(matchCanonicalTrack('best hardware hack')).toBe( + 'Best Hardware Hack' ); - expect(matchCanonicalTrack("Best hardware hack")).toBe( - "Best Hardware Hack" + expect(matchCanonicalTrack('Best hardware hack')).toBe( + 'Best Hardware Hack' ); }); - it("does not attempt to correct spelling", () => { - expect(matchCanonicalTrack("Best Hardwre Hack")).toBeNull(); - expect(matchCanonicalTrack("Best Assistive Technlogy")).toBeNull(); + it('does not attempt to correct spelling', () => { + expect(matchCanonicalTrack('Best Hardwre Hack')).toBeNull(); + expect(matchCanonicalTrack('Best Assistive Technlogy')).toBeNull(); }); - it("ingests all opt-in tracks and does not cap length", () => { + it('ingests all opt-in tracks and does not cap length', () => { const tracks = sortTracks( - "best hardware hack", - "", - "", - "Best Use of Gemini API; Best Use of MongoDB Atlas, Best Use of Vectara | Best Use of Auth0" + 'best hardware hack', + '', + '', + 'Best Use of Gemini API; Best Use of MongoDB Atlas, Best Use of Vectara | Best Use of Auth0' ); expect(tracks).toEqual([ - "Best Hardware Hack", - "Best Use of Gemini API", - "Best Use of MongoDB Atlas", - "Best Use of Vectara", - "Best Use of Auth0", + 'Best Hardware Hack', + 'Best Use of Gemini API', + 'Best Use of MongoDB Atlas', + 'Best Use of Vectara', + 'Best Use of Auth0', ]); }); - it("filters out excluded tracks", () => { + it('filters out excluded tracks', () => { const tracks = sortTracks( - "Best Hack for Social Good", + 'Best Hack for Social Good', "Hacker's Choice Award", - "", - "Best Hack for Social Good, Best Hardware Hack" + '', + 'Best Hack for Social Good, Best Hardware Hack' ); - expect(tracks).toEqual(["Best Hardware Hack"]); + expect(tracks).toEqual(['Best Hardware Hack']); }); }); diff --git a/__tests__/csvValidation.test.ts b/__tests__/csvValidation.test.ts index 91a8e2c5..88f595a5 100644 --- a/__tests__/csvValidation.test.ts +++ b/__tests__/csvValidation.test.ts @@ -1,12 +1,12 @@ -import { validateCsvBlob } from "@utils/csv-ingestion/csvAlgorithm"; +import { validateCsvBlob } from '@utils/csv-ingestion/csvAlgorithm'; -describe("csvAlgorithm validation", () => { +describe('csvAlgorithm validation', () => { it("silently ignores 'N/A' without warnings", async () => { const csv = - "Table Number,Project Status,Project Title,Track #1 (Primary Track),Track #2,Track #3,Opt-In Prizes\n" + - "12,Submitted (Gallery/Visible),Test Project,Best Beginner Hack,N/A,,\n"; + 'Table Number,Project Status,Project Title,Track #1 (Primary Track),Track #2,Track #3,Opt-In Prizes\n' + + '12,Submitted (Gallery/Visible),Test Project,Best Beginner Hack,N/A,,\n'; - const blob = new Blob([csv], { type: "text/csv" }); + const blob = new Blob([csv], { type: 'text/csv' }); const res = await validateCsvBlob(blob); expect(res.ok).toBe(true); @@ -15,18 +15,18 @@ describe("csvAlgorithm validation", () => { expect(res.report.issues).toEqual([]); }); - it("treats duplicate tracks as warnings (non-blocking)", async () => { + it('treats duplicate tracks as warnings (non-blocking)', async () => { const csv = - "Table Number,Project Status,Project Title,Track #1 (Primary Track),Track #2,Track #3,Opt-In Prizes\n" + - "87,Submitted (Gallery/Visible),PartyPal,Best UI/UX Design,Best UI/UX Design,,\n"; + 'Table Number,Project Status,Project Title,Track #1 (Primary Track),Track #2,Track #3,Opt-In Prizes\n' + + '87,Submitted (Gallery/Visible),PartyPal,Best UI/UX Design,Best UI/UX Design,,\n'; - const blob = new Blob([csv], { type: "text/csv" }); + const blob = new Blob([csv], { type: 'text/csv' }); const res = await validateCsvBlob(blob); expect(res.ok).toBe(true); expect(res.report.errorRows).toBe(0); expect(res.report.warningRows).toBe(1); - expect(res.report.issues[0].severity).toBe("warning"); - expect(res.report.issues[0].duplicateTracks).toEqual(["Best UI/UX Design"]); + expect(res.report.issues[0].severity).toBe('warning'); + expect(res.report.issues[0].duplicateTracks).toEqual(['Best UI/UX Design']); }); }); diff --git a/app/(api)/_actions/logic/checkTeamsPopulated.ts b/app/(api)/_actions/logic/checkTeamsPopulated.ts index c3cef818..5c4eaa39 100644 --- a/app/(api)/_actions/logic/checkTeamsPopulated.ts +++ b/app/(api)/_actions/logic/checkTeamsPopulated.ts @@ -1,11 +1,11 @@ -"use server"; +'use server'; -import { getDatabase } from "@utils/mongodb/mongoClient.mjs"; +import { getDatabase } from '@utils/mongodb/mongoClient.mjs'; export default async function checkTeamsPopulated() { try { const db = await getDatabase(); - const count = await db.collection("teams").countDocuments({}); + const count = await db.collection('teams').countDocuments({}); return { ok: true, populated: count > 0, count, error: null }; } catch (e) { const error = e as Error; diff --git a/app/(api)/_actions/logic/ingestTeams.ts b/app/(api)/_actions/logic/ingestTeams.ts index 043bfe19..aff32102 100644 --- a/app/(api)/_actions/logic/ingestTeams.ts +++ b/app/(api)/_actions/logic/ingestTeams.ts @@ -1,6 +1,6 @@ -"use server"; +'use server'; -import { CreateManyTeams } from "@datalib/teams/createTeams"; +import { CreateManyTeams } from '@datalib/teams/createTeams'; export default async function ingestTeams(body: object) { return CreateManyTeams(body); diff --git a/app/(api)/_actions/logic/validateCSV.ts b/app/(api)/_actions/logic/validateCSV.ts index f6061ce6..b8eca9c5 100644 --- a/app/(api)/_actions/logic/validateCSV.ts +++ b/app/(api)/_actions/logic/validateCSV.ts @@ -1,16 +1,16 @@ -"use server"; +'use server'; -import { validateCsvBlob } from "@utils/csv-ingestion/csvAlgorithm"; +import { validateCsvBlob } from '@utils/csv-ingestion/csvAlgorithm'; export default async function validateCSV(formData: FormData) { - const file = formData.get("file") as File | null; + const file = formData.get('file') as File | null; if (!file) { return { ok: false, body: null, validBody: null, report: null, - error: "Missing file", + error: 'Missing file', }; } diff --git a/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts b/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts index 337f2600..9941c4f4 100644 --- a/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts +++ b/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts @@ -1,12 +1,12 @@ -import csv from "csv-parser"; -import trackData from "@data/db_validation_data.json" assert { type: "json" }; -import { Readable } from "stream"; -import ParsedRecord from "@typeDefs/parsedRecord"; +import csv from 'csv-parser'; +import trackData from '@data/db_validation_data.json' assert { type: 'json' }; +import { Readable } from 'stream'; +import ParsedRecord from '@typeDefs/parsedRecord'; const filteredTracks = [ - "Best Hack for Social Good", + 'Best Hack for Social Good', "Hacker's Choice Award", - "N/A", + 'N/A', ]; export type CsvTrackAutoFix = { @@ -23,7 +23,7 @@ export type CsvRowIssue = { contactNames: string[]; memberEmails: string[]; memberNames: string[]; - severity: "error" | "warning"; + severity: 'error' | 'warning'; invalidTracks: string[]; excludedTracks: string[]; duplicateTracks: string[]; @@ -89,12 +89,12 @@ function splitOptInTracks(value: string): string[] { } function isSubmittedNonDraft(status: unknown): boolean { - const s = String(status ?? "") + const s = String(status ?? '') .trim() .toLowerCase(); if (!s) return false; - if (s.includes("draft")) return false; - return s.includes("submitted"); + if (s.includes('draft')) return false; + return s.includes('submitted'); } function extractContactInfoFromRow(data: Record): { @@ -112,20 +112,20 @@ function extractContactInfoFromRow(data: Record): { for (const [key, value] of Object.entries(data)) { const k = key.toLowerCase(); - const v = String(value ?? "").trim(); + const v = String(value ?? '').trim(); if (!v) continue; const isTrackColumn = - k.includes("track") || k.includes("opt-in") || k.includes("prize"); - const isProjectTitle = k.includes("project title"); + k.includes('track') || k.includes('opt-in') || k.includes('prize'); + const isProjectTitle = k.includes('project title'); const isContactish = - k.includes("contact") || k.includes("submitter") || k.includes("owner"); + k.includes('contact') || k.includes('submitter') || k.includes('owner'); - if (k.includes("email") || k.includes("e-mail")) { + if (k.includes('email') || k.includes('e-mail')) { v.split(/[\s,;|]+/g) .map((s) => s.trim()) .filter(Boolean) - .filter((s) => s.includes("@")) + .filter((s) => s.includes('@')) .forEach((email) => { memberEmails.add(email); if (isContactish) contactEmails.add(email); @@ -134,24 +134,24 @@ function extractContactInfoFromRow(data: Record): { } // Names - const isNameColumn = k.includes("name"); + const isNameColumn = k.includes('name'); const isMemberishColumn = - k.includes("member") || - k.includes("teammate") || - k.includes("team member") || - k.includes("participant"); + k.includes('member') || + k.includes('teammate') || + k.includes('team member') || + k.includes('participant'); const isProbablyNotAName = - k.includes("school") || - k.includes("major") || - k.includes("diet") || - k.includes("shirt") || - k.includes("pronoun") || - k.includes("role") || - k.includes("github") || - k.includes("linkedin") || - k.includes("devpost") || - k.includes("portfolio") || - k.includes("phone"); + k.includes('school') || + k.includes('major') || + k.includes('diet') || + k.includes('shirt') || + k.includes('pronoun') || + k.includes('role') || + k.includes('github') || + k.includes('linkedin') || + k.includes('devpost') || + k.includes('portfolio') || + k.includes('phone'); if (!isTrackColumn && !isProjectTitle && !looksLikeUrl(v)) { if (isNameColumn) { @@ -172,10 +172,10 @@ function extractContactInfoFromRow(data: Record): { } function canonicalHeaderKey(value: string): string { - return String(value ?? "") + return String(value ?? '') .trim() .toLowerCase() - .replace(/[^a-z0-9]/g, ""); + .replace(/[^a-z0-9]/g, ''); } function extractMemberColumnsFromTeamMember1( @@ -187,7 +187,7 @@ function extractMemberColumnsFromTeamMember1( const rows: Array<{ header: string; value: string }> = []; for (let i = startIndex; i < headers.length; i += 1) { const header = headers[i]; - rows.push({ header, value: String(data[header] ?? "").trim() }); + rows.push({ header, value: String(data[header] ?? '').trim() }); } return rows; } @@ -207,10 +207,10 @@ function validateAndCanonicalizeTracks(rawTracks: string[]): { const seen = new Set(); const excludedSet = new Set(filteredTracks.map((t) => normalizeTrackName(t))); - const silentlyIgnoredSet = new Set(["n/a"]); + const silentlyIgnoredSet = new Set(['n/a']); for (const raw of rawTracks) { - const trimmed = String(raw ?? "").trim(); + const trimmed = String(raw ?? '').trim(); if (!trimmed) continue; const normalized = normalizeTrackName(trimmed); @@ -351,22 +351,22 @@ export async function validateCsvBlob(blob: Blob): Promise<{ stream .pipe(csv()) - .on("headers", (h: string[]) => { + .on('headers', (h: string[]) => { headers = h; - const target = canonicalHeaderKey("Team member 1 first name"); + const target = canonicalHeaderKey('Team member 1 first name'); teamMember1StartIndex = h.findIndex( (header) => canonicalHeaderKey(header) === target ); }) - .on("data", (data) => { + .on('data', (data) => { rowIndex += 1; if ( - data["Table Number"] !== "" && - isSubmittedNonDraft(data["Project Status"]) + data['Table Number'] !== '' && + isSubmittedNonDraft(data['Project Status']) ) { - const projectTitle = data["Project Title"]; - const tableNumberRaw = data["Table Number"]; + const projectTitle = data['Project Title']; + const tableNumberRaw = data['Table Number']; const parsedTeamNumber = parseInt(tableNumberRaw); const { contactEmails, contactNames, memberEmails, memberNames } = @@ -379,10 +379,10 @@ export async function validateCsvBlob(blob: Blob): Promise<{ teamMember1StartIndex ); - const track1 = data["Track #1 (Primary Track)"] ?? ""; - const track2 = data["Track #2"] ?? ""; - const track3 = data["Track #3"] ?? ""; - const optIns = data["Opt-In Prizes"] ?? ""; + const track1 = data['Track #1 (Primary Track)'] ?? ''; + const track2 = data['Track #2'] ?? ''; + const track3 = data['Track #3'] ?? ''; + const optIns = data['Opt-In Prizes'] ?? ''; const { canonicalTracks, @@ -396,13 +396,13 @@ export async function validateCsvBlob(blob: Blob): Promise<{ const missingFields: string[] = []; if (!projectTitle || String(projectTitle).trim().length === 0) { - missingFields.push("Project Title"); + missingFields.push('Project Title'); } if (!Number.isFinite(parsedTeamNumber)) { - missingFields.push("Table Number"); + missingFields.push('Table Number'); } if (canonicalTracks.length === 0) { - missingFields.push("Tracks"); + missingFields.push('Tracks'); } if ( @@ -414,8 +414,8 @@ export async function validateCsvBlob(blob: Blob): Promise<{ ) { const severity = invalidTracks.length > 0 || missingFields.length > 0 - ? "error" - : "warning"; + ? 'error' + : 'warning'; issues.push({ rowIndex, teamNumberRaw: tableNumberRaw, @@ -446,12 +446,12 @@ export async function validateCsvBlob(blob: Blob): Promise<{ }); } }) - .on("end", () => { + .on('end', () => { const bestHardwareTeams = output.filter((team) => - team.tracks.includes("Best Hardware Hack") + team.tracks.includes('Best Hardware Hack') ); const otherTeams = output.filter( - (team) => !team.tracks.includes("Best Hardware Hack") + (team) => !team.tracks.includes('Best Hardware Hack') ); const orderedTeams = [...bestHardwareTeams, ...otherTeams]; @@ -462,18 +462,18 @@ export async function validateCsvBlob(blob: Blob): Promise<{ resolve(orderedTeams); }) - .on("error", (error) => reject(error)); + .on('error', (error) => reject(error)); }; parseBlob().catch(reject); }); - const errorRows = issues.filter((i) => i.severity === "error").length; - const warningRows = issues.filter((i) => i.severity === "warning").length; + const errorRows = issues.filter((i) => i.severity === 'error').length; + const warningRows = issues.filter((i) => i.severity === 'warning').length; const errorTeamNumbers = new Set( issues - .filter((i) => i.severity === "error" && i.teamNumber !== undefined) + .filter((i) => i.severity === 'error' && i.teamNumber !== undefined) .map((i) => i.teamNumber as number) ); const validBody = results.filter( @@ -495,7 +495,7 @@ export async function validateCsvBlob(blob: Blob): Promise<{ body: results, validBody, report, - error: ok ? null : "CSV validation failed. Fix errors and re-validate.", + error: ok ? null : 'CSV validation failed. Fix errors and re-validate.', }; } catch (e) { const error = e as Error; diff --git a/app/(pages)/admin/csv/page.tsx b/app/(pages)/admin/csv/page.tsx index 832db1d7..6d5c8982 100644 --- a/app/(pages)/admin/csv/page.tsx +++ b/app/(pages)/admin/csv/page.tsx @@ -1,8 +1,8 @@ -"use client"; -import validateCSV from "@actions/logic/validateCSV"; -import ingestTeams from "@actions/logic/ingestTeams"; -import checkTeamsPopulated from "@actions/logic/checkTeamsPopulated"; -import React, { useEffect, useState } from "react"; +'use client'; +import validateCSV from '@actions/logic/validateCSV'; +import ingestTeams from '@actions/logic/ingestTeams'; +import checkTeamsPopulated from '@actions/logic/checkTeamsPopulated'; +import React, { useEffect, useState } from 'react'; type ValidationResponse = { ok: boolean; @@ -15,7 +15,7 @@ type ValidationResponse = { export default function CsvIngestion() { const [pending, setPending] = useState(false); const [validating, setValidating] = useState(false); - const [response, setResponse] = useState(""); + const [response, setResponse] = useState(''); const [validation, setValidation] = useState(null); const [file, setFile] = useState(null); @@ -44,16 +44,16 @@ export default function CsvIngestion() { const validateHandler = async () => { if (!file) { - setResponse("Please choose a CSV file first."); + setResponse('Please choose a CSV file first.'); return; } setValidating(true); const formData = new FormData(); - formData.append("file", file); + formData.append('file', file); const res = (await validateCSV(formData)) as ValidationResponse; setValidation(res); - setResponse(""); + setResponse(''); setValidating(false); }; @@ -83,10 +83,10 @@ export default function CsvIngestion() { }; const canonKey = (value: string) => - String(value ?? "") + String(value ?? '') .trim() .toLowerCase() - .replace(/[^a-z0-9]/g, ""); + .replace(/[^a-z0-9]/g, ''); const buildTeamMemberLines = (issue: any): string[] => { const cols = Array.isArray(issue?.memberColumnsFromTeamMember1) @@ -96,13 +96,13 @@ export default function CsvIngestion() { const findByHeaderPrefix = (prefix: string): string => { const p = canonKey(prefix); for (const c of cols) { - const header = String(c?.header ?? ""); - const value = String(c?.value ?? "").trim(); + const header = String(c?.header ?? ''); + const value = String(c?.value ?? '').trim(); if (!header) continue; const hk = canonKey(header); if (hk.startsWith(p)) return value; } - return ""; + return ''; }; const lines: string[] = []; @@ -113,19 +113,19 @@ export default function CsvIngestion() { findByHeaderPrefix(`Team member ${n} email`) || findByHeaderPrefix(`Team member ${n} e-mail`); - const fullName = `${first} ${last}`.trim().replace(/\s+/g, " "); + const fullName = `${first} ${last}`.trim().replace(/\s+/g, ' '); if (!fullName && !email) continue; - const namePart = fullName || "(no name)"; - const emailPart = email ? ` — ${email}` : ""; + const namePart = fullName || '(no name)'; + const emailPart = email ? ` — ${email}` : ''; lines.push(`${namePart}${emailPart}`); } return lines; }; - const buildCopyText = (severity: "error" | "warning") => { - if (!validation?.report) return ""; + const buildCopyText = (severity: 'error' | 'warning') => { + if (!validation?.report) return ''; const rows = validation.report.issues .filter((i: any) => i.severity === severity) @@ -133,30 +133,30 @@ export default function CsvIngestion() { const header = `Team ${i.teamNumberRaw} — ${i.projectTitle}`; const submitterName = i.contactNames?.length - ? `Submitter: ${i.contactNames.join(", ")}` - : ""; + ? `Submitter: ${i.contactNames.join(', ')}` + : ''; const submitterEmail = i.contactEmails?.length - ? `Submitter Email: ${i.contactEmails.join(", ")}` - : ""; + ? `Submitter Email: ${i.contactEmails.join(', ')}` + : ''; const memberLines = buildTeamMemberLines(i); const membersBlock = memberLines.length - ? ["Members:", ...memberLines.map((l) => ` ${l}`)].join("\n") - : ""; + ? ['Members:', ...memberLines.map((l) => ` ${l}`)].join('\n') + : ''; return [header, submitterName, submitterEmail, membersBlock] .filter(Boolean) - .join("\n"); + .join('\n'); }); - return rows.join("\n\n"); + return rows.join('\n\n'); }; const copyToClipboard = async (text: string) => { if (!text) return; try { await navigator.clipboard.writeText(text); - setResponse("Copied to clipboard."); + setResponse('Copied to clipboard.'); } catch { setResponse(text); } @@ -188,12 +188,12 @@ export default function CsvIngestion() { const next = e.target.files?.[0] ?? null; setFile(next); setValidation(null); - setResponse(""); + setResponse(''); }} />
    {validation.report.issues - .filter((i: any) => i.severity === "error") + .filter((i: any) => i.severity === 'error') .map((i: any) => (
  • Team {i.teamNumberRaw} — {i.projectTitle} {i.contactNames?.length ? ( - <> (Submitter: {i.contactNames.join(", ")}) + <> (Submitter: {i.contactNames.join(', ')}) ) : null} {i.missingFields?.length ? ( - <> (Missing: {i.missingFields.join(", ")}) + <> (Missing: {i.missingFields.join(', ')}) ) : null} {i.invalidTracks?.length ? ( - <> (Invalid tracks: {i.invalidTracks.join(", ")}) + <> (Invalid tracks: {i.invalidTracks.join(', ')}) ) : null} {buildTeamMemberLines(i).length ? (
                                 {buildTeamMemberLines(i)
                                   .map((l) => `Member: ${l}`)
    -                              .join("\n")}
    +                              .join('\n')}
                               
    ) : null}
  • @@ -261,24 +261,24 @@ export default function CsvIngestion() {

    Warnings

      {validation.report.issues - .filter((i: any) => i.severity === "warning") + .filter((i: any) => i.severity === 'warning') .map((i: any) => (
    • Team {i.teamNumberRaw} — {i.projectTitle} {i.contactNames?.length ? ( - <> (Submitter: {i.contactNames.join(", ")}) + <> (Submitter: {i.contactNames.join(', ')}) ) : null} {i.duplicateTracks?.length ? ( - <> (Duplicates: {i.duplicateTracks.join(", ")}) + <> (Duplicates: {i.duplicateTracks.join(', ')}) ) : null} {i.excludedTracks?.length ? ( - <> (Excluded: {i.excludedTracks.join(", ")}) + <> (Excluded: {i.excludedTracks.join(', ')}) ) : null} {i.autoFixedTracks?.length ? ( <> (Auto-fixed casing/spacing) @@ -287,7 +287,7 @@ export default function CsvIngestion() {
                                   {buildTeamMemberLines(i)
                                     .map((l) => `Member: ${l}`)
      -                              .join("\n")}
      +                              .join('\n')}
                                 
      ) : null}
    • @@ -313,7 +313,7 @@ export default function CsvIngestion() { onClick={uploadValidHandler} disabled={pending || validating || !validation.validBody?.length} > - {pending ? "Uploading..." : "Upload Valid Teams Only"} + {pending ? 'Uploading...' : 'Upload Valid Teams Only'}