// CSV roster import parsing — Phase 5A §29. Deliberately hand-rolled, not a CSV library: the // expected input is simple (no quoted fields containing commas expected in practice for // names/identifiers), and pulling in a dependency for a three-column parser isn't justified // (ponytail: stdlib/hand-rolled first). Validates the WHOLE file and returns every row-level // error at once — the caller decides whether to import (transactional, all-or-nothing). const REQUIRED_HEADERS = ['display_name']; const KNOWN_HEADERS = ['display_name', 'email', 'login_identifier', 'cohort_id']; const FORBIDDEN_HEADERS = ['password', 'password_hash', 'credential_token_hash']; // never accepted from a file — see admin-routes.js's import route comment function splitCsvLine(line) { // No quoted-comma support needed for this column set (names/identifiers/cohort slugs don't // contain commas in practice) — a plain split covers the real input shape. return line.split(',').map((cell) => cell.trim()); } function parseStudentCsv(csv) { const lines = csv.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0); const errors = []; if (!lines.length) return { rows: [], errors: [{ row: 0, message: 'CSV is empty' }] }; const headers = splitCsvLine(lines[0]).map((h) => h.toLowerCase()); for (const required of REQUIRED_HEADERS) { if (!headers.includes(required)) errors.push({ row: 0, message: `missing required column: ${required}` }); } for (const forbidden of FORBIDDEN_HEADERS) { if (headers.includes(forbidden)) errors.push({ row: 0, message: `column '${forbidden}' is never accepted from a file — credentials are always generated server-side` }); } const unknown = headers.filter((h) => !KNOWN_HEADERS.includes(h)); if (unknown.length) errors.push({ row: 0, message: `unknown column(s): ${unknown.join(', ')}` }); if (errors.length) return { rows: [], errors }; const seenEmails = new Set(); const seenIdentifiers = new Set(); const rows = []; for (let i = 1; i < lines.length; i++) { const rowNum = i + 1; // 1-indexed with header as row 1, matching what a spreadsheet user sees const cells = splitCsvLine(lines[i]); if (cells.length !== headers.length) { errors.push({ row: rowNum, message: `expected ${headers.length} columns, got ${cells.length}` }); continue; } const raw = Object.fromEntries(headers.map((h, idx) => [h, cells[idx]])); if (!raw.display_name) { errors.push({ row: rowNum, message: 'display_name is required' }); continue; } const email = raw.email ? raw.email.toLowerCase() : null; const loginIdentifier = raw.login_identifier || null; if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { errors.push({ row: rowNum, message: `invalid email: ${raw.email}` }); continue; } if (email && seenEmails.has(email)) { errors.push({ row: rowNum, message: `duplicate email within this file: ${email}` }); continue; } if (loginIdentifier && seenIdentifiers.has(loginIdentifier)) { errors.push({ row: rowNum, message: `duplicate login_identifier within this file: ${loginIdentifier}` }); continue; } if (email) seenEmails.add(email); if (loginIdentifier) seenIdentifiers.add(loginIdentifier); rows.push({ display_name: raw.display_name, email, login_identifier: loginIdentifier, cohort_id: raw.cohort_id || null }); } if (errors.length) return { rows: [], errors }; return { rows, errors: [] }; } module.exports = { parseStudentCsv };