feat(messaging): polls / voting (LC-66) #164

Merged
nrupard merged 2 commits from feat/lc-66-polls into main 2026-05-20 19:19:29 +02:00
Owner

Summary

Structured polls (LC-66). A poll is a message with a question + 2-10 options; room members vote one click, the block re-renders in place with tallies. Single- or multi-choice, optional anonymous mode, optional auto-close.

Design

  • Schema (0038_polls.sql): polls(message_id PK -> messages ON DELETE CASCADE, question, allows_multi, anonymous, closes_at, closed_at), poll_options, poll_votes. All cascade from the anchor message.
  • Anchored to a message (body = question) so search / quote / pin work. db::polls::create inserts message + poll + options in one transaction (atomic); the create path calls finalize_message_send so the bubble broadcasts to the room.
  • Render: MessageView grows poll: Option<PollView>, loaded by build_poll_view at every render site. Block renders in #poll-{id} under the body. Non-anonymous lists voter names; anonymous shows counts only and never resolves identities (enforced in build_poll_view, so identity never leaves the server).
  • Vote: POST /poll/{id}/vote toggles a row + broadcasts ChatEvent::PollUpdated; per-connection WS render re-renders #poll-{id} innerHTML so each viewer's highlight stays correct. Single-choice moves/clears; multi permits several. Closed poll => 409.
  • Entry points: a compose-box "Create poll" button (modal -> POST /room/{id}/poll) and the slash command /poll "Q" "A" "B" ... (parsed in post_message after the existing gates).
  • Auto-close: spawn_polls_closer (30s tick) closes due polls and broadcasts a re-render.

Acceptance criteria

  • Posting a poll creates the message + poll + options atomically.
  • Voting upserts poll_votes and broadcasts a re-rendered fragment.
  • allows_multi=false = one option; true = multiple.
  • Anonymous polls never leak voter identity (no endpoint resolves voters for anonymous).
  • Closed poll renders totals but rejects votes (409).
  • Deleting the message cascades (poll + options + votes).
  • Slash command /poll "Q" "A" "B".

Open questions resolved for v1: counts always visible; max 10 options / 300-char question / 150-char option; edit-after-vote not exposed (rename/add deferred).

Tests

routes_polls.rs: atomic create (modal + slash), single move/toggle, multi, closed-poll 409, anonymous privacy, delete cascade. Migration 0038 appended to hand-rolled lists. just check, just test, just test-saas green.

🤖 Generated with Claude Code

## Summary Structured polls (LC-66). A poll is a message with a question + 2-10 options; room members vote one click, the block re-renders in place with tallies. Single- or multi-choice, optional anonymous mode, optional auto-close. ## Design - **Schema** (`0038_polls.sql`): `polls(message_id PK -> messages ON DELETE CASCADE, question, allows_multi, anonymous, closes_at, closed_at)`, `poll_options`, `poll_votes`. All cascade from the anchor message. - **Anchored to a message** (body = question) so search / quote / pin work. `db::polls::create` inserts message + poll + options in one transaction (atomic); the create path calls `finalize_message_send` so the bubble broadcasts to the room. - **Render**: `MessageView` grows `poll: Option<PollView>`, loaded by `build_poll_view` at every render site. Block renders in `#poll-{id}` under the body. Non-anonymous lists voter names; anonymous shows counts only and never resolves identities (enforced in `build_poll_view`, so identity never leaves the server). - **Vote**: `POST /poll/{id}/vote` toggles a row + broadcasts `ChatEvent::PollUpdated`; per-connection WS render re-renders `#poll-{id}` innerHTML so each viewer's highlight stays correct. Single-choice moves/clears; multi permits several. Closed poll => 409. - **Entry points**: a compose-box "Create poll" button (modal -> `POST /room/{id}/poll`) and the slash command `/poll "Q" "A" "B" ...` (parsed in `post_message` after the existing gates). - **Auto-close**: `spawn_polls_closer` (30s tick) closes due polls and broadcasts a re-render. ## Acceptance criteria - [x] Posting a poll creates the message + poll + options atomically. - [x] Voting upserts `poll_votes` and broadcasts a re-rendered fragment. - [x] `allows_multi=false` = one option; `true` = multiple. - [x] Anonymous polls never leak voter identity (no endpoint resolves voters for anonymous). - [x] Closed poll renders totals but rejects votes (409). - [x] Deleting the message cascades (poll + options + votes). - [x] Slash command `/poll "Q" "A" "B"`. Open questions resolved for v1: counts always visible; max 10 options / 300-char question / 150-char option; edit-after-vote not exposed (rename/add deferred). ## Tests `routes_polls.rs`: atomic create (modal + slash), single move/toggle, multi, closed-poll 409, anonymous privacy, delete cascade. Migration `0038` appended to hand-rolled lists. `just check`, `just test`, `just test-saas` green. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(messaging): polls / voting (LC-66)
All checks were successful
Check / clippy + fmt + tests (pull_request) Successful in 1m22s
4f9fa6b070
Add structured polls anchored to a message: a question + 2-10 options, one-click voting that re-renders the poll block in place across the room, single- or multi-choice, optional anonymous mode, and an optional auto-close deadline.

Schema (migration 0038): polls(message_id PK -> messages ON DELETE CASCADE, question, allows_multi, anonymous, closes_at, closed_at), poll_options, poll_votes. All three cascade from the anchor message (FK enforcement is on via the sqlx connect default), so a hard delete removes the poll, options, and votes.

A poll is anchored to a real messages row (body = question) so search, quote-reply, and pinning keep working. db::polls::create inserts the message + poll + options in one transaction (atomic per the acceptance criteria); the create path then calls finalize_message_send so the rendered bubble (with its poll block) broadcasts to the room.

MessageView grows a poll: Option<PollView> field, loaded by build_poll_view at every render site (page history, live NewMessage, edits, threads, DMs). The poll block renders beneath the body in #poll-{id}; for non-anonymous polls it lists voter names, for anonymous polls it shows counts only and never resolves voter identities (enforced in build_poll_view, so identity never leaves the server through any path).

Voting: POST /poll/{message_id}/vote toggles a poll_votes row and broadcasts ChatEvent::PollUpdated; the per-connection WS render re-renders #poll-{id} innerHTML so each viewer's "your vote" highlight stays correct. allows_multi=false enforces one option (a new pick moves the vote, re-clicking clears it); allows_multi=true permits several. A closed poll (closed_at set, or closes_at passed) rejects votes with 409 Conflict.

Entry points: a "Create poll" compose-box button opens a modal (POST /room/{id}/poll), and the slash command /poll "Question" "Option A" "Option B" ... posts a poll through the same create path (parsed in post_message after the existing access / posting gates).

Background: spawn_polls_closer (30s tick) closes polls whose closes_at has passed and broadcasts a re-render so the UI flips to results-only.

Tests: routes_polls.rs covers atomic creation (modal + slash), single move/toggle, multi, the closed-poll 409, anonymous voter privacy, and message-delete cascade. Migration 0038 appended to the hand-rolled migration lists. just check, just test, just test-saas all green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
perf(polls): skip build_poll_view for non-poll messages; test non-member vote (LC-66)
All checks were successful
Check / clippy + fmt + tests (pull_request) Successful in 1m21s
727a3686ee
The bulk message-history loaders (room, DM, thread) called build_poll_view per message, which probed the polls table once per row. Add db::polls::poll_message_ids to fetch the poll ids for the whole page in one query, and gate build_poll_view on membership in that set so ordinary messages skip it entirely. Per-poll work is unchanged (polls are rare per page).

Also add a test asserting a non-member of a private room cannot vote (403, no vote recorded), closing a coverage gap on the vote authorization path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
nrupard deleted branch feat/lc-66-polls 2026-05-20 19:19:30 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!164
No description provided.