Skip to content

v0.4.0 — per-question multi-choice ballots

Choose a tag to compare

@asm0dey asm0dey released this 18 May 08:59
· 96 commits to main since this release
32b3876

Highlights

Per-question arity model. Every question now carries minSelections and maxSelections instead of an implicit single-choice shape. (1, 1) = radio buttons (single-choice); anything else = checkboxes; min=0 lets voters submit an empty ballot ("Skip"). The voter SPA, backoffice editor, and slidev deck panels all honour the new shape.

Destructive-edit lock. Once a question has any stored votes, the backend rejects structural edits (option-delete, question-delete, arity changes) with RESOURCE_HAS_VOTES (HTTP 409). Prompt and option-label edits remain free.

Snapshot-only SSE. The legacy tally-delta channel is gone; every ballot change triggers a fresh snapshot broadcast. Domain events are signal-only (pollId, questionId, emittedAt). The snapshot payload now carries voterCount so multi-choice tallies can show N voters · M selections.

CORS-aware hint. When a slidev deck's origin isn't in the poll's Allowed origins, the panel surfaces an actionable hint instead of silently sitting on live updates paused.

Backend

  • DB migration: poll_questions.min_selections / max_selections (NOT NULL DEFAULT 1, CHECK constraint), votes.option_ids uuid[] (NOT NULL, empty allowed), GIN index on Postgres. poll_questions.type and votes.option_id dropped. H2 baseline mirrored.
  • New endpoint behaviour on POST /api/polls/{slug}/votes: body is { "optionIds": ["..."] }; legacy { "optionId": "..." } rejected with VALIDATION_FAILED.
  • VoteService validates ballot size against arity, rejects duplicates and unknown options, accepts empty ballots when min=0.
  • PollService.update enforces RESOURCE_HAS_VOTES lock on destructive question edits.
  • New voteCount field threaded through admin response builders. QuestionDto exposes it; backoffice editor disables structural controls when voteCount > 0.
  • H2 tally uses in-process array fanout (jOOQ unnest(parameter) not portable to H2).

Frontends

  • Voter (PollView.vue): radio when min=max=1, checkboxes otherwise; submit label flips between Skip (no selections) and Submit answer; cap-disable on unchecked options at maxSelections; hint text tracks arity.
  • Backoffice (PollEditorPage.vue): Pick one / Pick many toggle, min/max number inputs, structural-control lock keyed on voteCount.
  • Slidev (PollPanel.vue): renders multi-choice results, threads arity from snapshot, surfaces CORS-misconfig hint when stream stays paused.
  • Shared (@slidev-polls/shared): types and SSE client updated; tally/tally-delta consumers removed.
  • ResultsPanel: N voters · M selections footer for multi-choice questions.

Tests

  • New ITs: QuestionArityRoundTripIT, VoteRepositoryImplArrayBallotIT, VoteSubmissionMultiChoiceIT, PollRepositoryImplArityRoundTripIT, PollServiceLockTest, QuestionLockIT, TallyBroadcasterSnapshotIT.
  • Frontend: arity, lock, abstain, multi-choice, CORS-hint test files added across voter/backoffice/slidev.
  • E2E: multi-choice + abstain happy paths; deck question-switching covered for both signed-in presenter and anonymous viewer; CORS-clean assertions on every spec.

Quality

  • Sonar critical findings (cognitive complexity in PollService.enforceVoteLock, duplicated "snapshot" literal in TallyBroadcaster) addressed.
  • GitGuardian: test-fixture password (correct-horse) allowlisted.

Migration

  • DB: Flyway applies on boot. No app-level data backfill needed; the migration copies each votes.option_id into option_ids = ARRAY[option_id].
  • API clients that POST single-choice votes must switch to { "optionIds": [...] }.

Full changelog

v0.3.0...v0.4.0