v0.4.0 — per-question multi-choice ballots
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.typeandvotes.option_iddropped. H2 baseline mirrored. - New endpoint behaviour on
POST /api/polls/{slug}/votes: body is{ "optionIds": ["..."] }; legacy{ "optionId": "..." }rejected withVALIDATION_FAILED. VoteServicevalidates ballot size against arity, rejects duplicates and unknown options, accepts empty ballots whenmin=0.PollService.updateenforcesRESOURCE_HAS_VOTESlock on destructive question edits.- New
voteCountfield threaded through admin response builders.QuestionDtoexposes it; backoffice editor disables structural controls whenvoteCount > 0. - H2 tally uses in-process array fanout (jOOQ
unnest(parameter)not portable to H2).
Frontends
- Voter (
PollView.vue): radio whenmin=max=1, checkboxes otherwise; submit label flips betweenSkip(no selections) andSubmit answer; cap-disable on unchecked options atmaxSelections; hint text tracks arity. - Backoffice (
PollEditorPage.vue):Pick one/Pick manytoggle, min/max number inputs, structural-control lock keyed onvoteCount. - 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-deltaconsumers removed. ResultsPanel:N voters · M selectionsfooter 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 inTallyBroadcaster) 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_idintooption_ids = ARRAY[option_id]. - API clients that POST single-choice votes must switch to
{ "optionIds": [...] }.