You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Two new CVR parser functions — one for CSV, one for JSON — that accept the same shape the election's own ballot-data export produces, with a voter_id column/field added, so an admin can round-trip an export back in with voter_ids filled in. Each parser validates the file's structure against the target election up front (whole-file reject on mismatch) and handles voter_id per the open/closed election rules. The existing ranked-CVR parser is rewired onto the same output shape so there's one consistent parser interface.
This ticket is parsing/validation logic only, verified via unit tests — it isn't wired into any dialog yet (that's a later ticket).
Acceptance criteria
Common parser signature, no runtime registry — caller picks a parser by file extension:
typeCvrParser=(fileText: string,election: Election)=>{ballots: (NewBallotWithVoterID|undefined)[];// undefined = a row skipped/rejected during parsingerrors: ParseError[];}
CSV parser: accepts the election's existing ballot-data export header with a voter_id column prepended — voter_id, ballot_id, precinct, <one column per race/candidate>, [overvote_rank, has_duplicate_rank for ranked races]. voter_id may be blank per row.
JSON parser: accepts { Election, Ballots: (AnonymizedBallot & { voter_id?: string })[] }, ignoring a Results field if present.
Structural validation is a whole-file reject, before any row is parsed:
CSV: match column headers against candidate names per race (name-matching only — the export never writes internal race/candidate IDs). An unrecognized column, or a race with zero matched candidate columns, rejects the entire file.
JSON: compare the embedded Election.races against the target election's races directly by ID.
voter_id handling:
Open elections (voter-ID authentication enabled): a blank voter_id is auto-generated by the parser before the row is returned (never left blank — the roll-lookup path would otherwise fall back to the uploading admin's own user id, silently collapsing every voter-id-less row onto one roll entry).
Closed elections: a voter_id must resolve to an existing roll entry; a missing/non-matching one is a per-row rejection (returned as undefined in ballots plus a corresponding entry in errors), not a whole-file reject.
Each parser (including the rewired ranked-CVR one) computes race order and encodes each row into wire format internally using computeRaceOrder/encodeBallotRow from Extract shared upload utilities and rewire Upload Elections onto them #1605, returning ready-to-upload NewBallotWithVoterID rows directly.
Unit tests: valid round-trip of an exported file (CSV and JSON), a column/structure mismatch producing a whole-file reject (both formats), a closed-election missing/invalid voter_id producing a per-row error, an open-election blank voter_id being auto-generated.
The existing (currently unused) useUploadBallots API hook's request body is fixed to include the race_order field the endpoint actually expects.
Parent
Part of #1603.
What to build
Two new CVR parser functions — one for CSV, one for JSON — that accept the same shape the election's own ballot-data export produces, with a
voter_idcolumn/field added, so an admin can round-trip an export back in withvoter_ids filled in. Each parser validates the file's structure against the target election up front (whole-file reject on mismatch) and handlesvoter_idper the open/closed election rules. The existing ranked-CVR parser is rewired onto the same output shape so there's one consistent parser interface.This ticket is parsing/validation logic only, verified via unit tests — it isn't wired into any dialog yet (that's a later ticket).
Acceptance criteria
voter_idcolumn prepended —voter_id, ballot_id, precinct, <one column per race/candidate>, [overvote_rank, has_duplicate_rank for ranked races].voter_idmay be blank per row.{ Election, Ballots: (AnonymizedBallot & { voter_id?: string })[] }, ignoring aResultsfield if present.Election.racesagainst the target election's races directly by ID.voter_idhandling:voter_idis auto-generated by the parser before the row is returned (never left blank — the roll-lookup path would otherwise fall back to the uploading admin's own user id, silently collapsing every voter-id-less row onto one roll entry).voter_idmust resolve to an existing roll entry; a missing/non-matching one is a per-row rejection (returned asundefinedinballotsplus a corresponding entry inerrors), not a whole-file reject.computeRaceOrder/encodeBallotRowfrom Extract shared upload utilities and rewire Upload Elections onto them #1605, returning ready-to-uploadNewBallotWithVoterIDrows directly.voter_idproducing a per-row error, an open-election blankvoter_idbeing auto-generated.useUploadBallotsAPI hook's request body is fixed to include therace_orderfield the endpoint actually expects.Blocked by
#1605 (needs
computeRaceOrder/encodeBallotRowextracted first).