Skip to content

CVR parsers for the ballot-export CSV and JSON formats #1609

Description

@ArendPeter

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_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:
    type CvrParser = (fileText: string, election: Election) => {
        ballots: (NewBallotWithVoterID | undefined)[]; // undefined = a row skipped/rejected during parsing
        errors: 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.

Blocked by

#1605 (needs computeRaceOrder/encodeBallotRow extracted first).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions