# Polls in text channels A member with the right permission can post a poll in a text or announcement channel, the way Discord does: a question, 2–10 answers, and a closing time. It is a message kind, not an activity. The voice-room Poll activity (`plugins/poll`) is separate and unchanged. ## 1. Behaviour - **Create:** the `+` button in the composer opens a menu with **Create poll** (only for members who may post polls in that channel). The dialog asks for: - a question, 1–300 characters; - 2–10 answers, 1–80 characters each, no two the same after trimming and ignoring case (`Pizza` and ` PIZZA ` are the same answer); - **Allow multiple answers** (off by default); - a duration: 1 h, 4 h, 8 h, 24 h (default), 3 days or 7 days. - **Vote:** anyone who can read the channel. Single choice, or any number of answers (up to all of them) when multiple answers are allowed. A member can change or remove their vote until the poll closes. - **Results:** counts show to a member once they have voted, and to everyone once the poll has closed. Before voting, a member sees the answers without counts, and the number of people who voted. - **Close:** automatic at the closing time, or early by the poll's creator or anyone with **Manage Messages** (owner and administrators included). A closed poll stays in the channel, read-only, with the final results and a **Closed** badge. - **Edit and delete:** the question cannot be edited (people have voted on it). Deleting the message deletes the poll and every ballot. - **Live:** votes and closes reach every open lobby without a reload. The card announces a new voter total politely to screen readers; the countdown is not announced. ## 2. Permission `create_polls` ("Create polls" / "Anket oluştur"), in the role editors next to the text permissions. **Where owners set it:** Community settings → Apps → the **Poll** card → "Who can create polls in text channels". It lists the roles, highest first, with a box each. This is not a second setting: each box *is* that role's `create_polls` permission, saved through `PATCH /api/servers/{id}/roles/{roleId}`, so the card and the role editors always agree and the role API's checks apply unchanged (Manage Roles, only roles below your highest one, never granting a permission you don't hold). Roles with `administrator` show ticked and locked. Without Manage Roles the card is read-only and says so. The card shows whether or not the Poll app is installed: chat polls don't depend on it. - New servers: the `Owner` role carries it. `@everyone` does not. - Existing servers: migration 0047 adds it to every role that already has `administrator` or `manage_messages` (owner and moderator-style roles), never to `@everyone`. Owners can grant it to any role. - The owner and administrators always pass (`administrator` implies every permission). - Posting also needs **Send Messages** in that channel, and the channel must be `text` or `announcement`. The same gates as a message apply to posting a poll: email verification (`message`), channel visibility, member timeouts, `@everyone` or `@here` needing **Mention Everyone**, the Moderation Bot (it judges the question and every answer together, exactly like a new message), and the message rate limit (polls share the `messages-create` budget). Voting is gated like a reaction: a timed-out member, or an unverified account in `required` mode (`reaction`), cannot vote. ## 3. API All routes are under `/api/servers/{id}/channels/{channelId}` and use the session cookie. | Method | Path | Body | Answer | |---|---|---|---| | `POST` | `/polls` | `{ question, options: string[], allowMultiple?, durationHours? }` | `201 { message, poll }` | | `GET` | `/polls/{pollId}` | — | `{ poll }` | | `PUT` | `/polls/{pollId}/vote` | `{ choices: number[] }` (option indexes) | `{ poll }` | | `DELETE` | `/polls/{pollId}/vote` | — | `{ poll }` | | `POST` | `/polls/{pollId}/close` | — | `{ poll }` | `durationHours` is one of `1, 4, 8, 24, 72, 168` (default 24). `allowMultiple` defaults to `false`. Refusals: | Status | `code` | When | |---|---|---| | 400 | `invalid_poll`, `poll_duplicate_option` | the body breaks a rule above | | 400 | `poll_channel_type` | not a text or announcement channel | | 400 | `invalid_vote`, `vote_single_choice`, `vote_duplicate`, `vote_out_of_range` | a bad `choices` list | | 403 | — / `email_unverified` | no permission, timed out, not verified | | 403 | `poll_close_forbidden` | closing someone else's poll without Manage Messages | | 403 | `actor_timed_out` | closing someone else's poll while timed out (the creator may still close their own) | | 404 | `poll_not_found` | unknown poll, deleted message, or a poll in another channel | | 409 | `poll_closed` | voting, removing a vote or closing after the poll closed | | 422 | `blocked_by_moderation` | the Moderation Bot refused it (same body as a message) | The `poll` object is the only shape a poll leaves the server in: ```ts { id, messageId, question, options: Array<{ text: string; votes: number | null }>, // null until results are visible to you allowMultiple, closesAt, closedAt, closed, totalVoters, // always shown myChoices: number[], // yours only resultsVisible, version, // bumped by every vote, removal and close } ``` **Message routes.** A poll is stored as a message whose `content` is the question, so search, notifications and bots see plain text. Its `metadata.poll = { id }` is written only by the poll route: `poll` is a reserved metadata key and the messages API refuses it from clients. `GET …/messages` adds `poll` (projected for the caller) to each poll message, with one batched query per page; `GET …/messages/{messageId}` does the same. `PATCH` with `content` on a poll message answers 403 `poll_message_readonly` (pinning still works). **Realtime.** On the chat topic `chat:{serverId}:{channelId}`: - a new poll is a normal `message` event whose `message.poll` is the poll as everyone sees it at creation (no votes yet); - after a vote, a vote removal or a close: `{ type: 'poll_update', poll: { id, messageId, counts, totalVoters, closesAt, closedAt, closed, version } }`. It carries ids and counts, never a voter. The lobby shows the counts to members who have voted (and to everyone once closed); a member who has not voted only sees the voter total move. - Each write reads its tally after it commits, so updates can arrive out of order. The lobby drops an update older than the `version` it shows, a closed poll never reopens, and a REST answer older than the newest update seen is brought up to it (keeping the answer's own choices). The lobby also handles `message_update` (refetches that one message) and `message_delete` (drops it), so edits and deletes appear without a reload. The realtime client re-sends its subscriptions when the gateway says `hello`: the gateway drops a subscribe that arrives before it has authenticated the socket, which used to leave a lobby without live updates until a reload when sign-in checks were slow. **Bots** see a poll as a plain `message_create` whose `content` is the question. There is no poll data or poll API for bots in this version, and they never receive `poll_update`. **Audit log:** `poll.create` and `poll.close`. Votes are never audited. ## 4. Data model (migration 0047) - `message_polls`: `id`, `message_id` (unique, FK → `messages` ON DELETE CASCADE), `channel_id` (FK → `channels` ON DELETE CASCADE), `creator_user_id` (SET NULL), `question`, `options` (JSON array of 2–10 strings), `allow_multiple`, `closes_at`, `closed_at` (early close), `closed_by_user_id`, `version` (bumped by every vote, removal and close), `created_at`. - `message_poll_votes`: primary key `(poll_id, user_id, option_index)`, FK → `message_polls` and `users`, both ON DELETE CASCADE. A single-choice vote is one row; a multiple-choice vote is one row per chosen answer. The message and its poll are written in one transaction. Closing is lazy: a poll is closed once `closes_at` has passed or `closed_at` is set; nothing runs at expiry. Every write to a poll (vote, removal, close) locks the poll row and bumps `version` in the same transaction, so writes run one after another: two devices voting at once cannot leave a single-choice poll with two rows, a vote cannot slip in after a close, and versions are ordered. Messages are soft-deleted (`deleted_at`), which the FK cascade never sees, so the delete route soft-deletes a poll message and deletes its poll (and with it the ballots) in one transaction; if that fails, nothing is deleted. A hard delete (a channel removed) cascades. ## 5. Anonymity Results are anonymous to everyone, the poll's creator, moderators and the server owner included: - every API answer and every realtime event carries counts, the number of voters, and the caller's own choices, never another member's choice; - votes are not written to the audit log. **The limit:** the database does store who chose what (`message_poll_votes.user_id`). It has to, so a vote can be changed or removed and "your vote" shows on every device. Anyone with direct access to the database (the operator of the instance, a backup) can read it. The promise is that LobbyForge never exposes it, not that it does not exist. Pre-vote hiding is a rule of the interface, not a secret: the `poll_update` event carries counts to everyone who can read the channel, and the card simply does not show them until you vote. The REST answers do hide them.