feat: polls with live voting via WebSocket

- /poll command opens creation modal (2-10 options)
- PollDisplay with vote bars, percentages, live WS updates
- Backend: polls/poll_options/poll_votes tables, Create/Get/Vote endpoints
- attachPolls enriches message list responses
- POLL_UPDATE broadcast on vote for real-time sync
This commit is contained in:
2026-07-02 12:35:05 -04:00
parent d8b4defaff
commit 40f8d193ff
10 changed files with 741 additions and 1 deletions
+106
View File
@@ -166,6 +166,7 @@ type messageResponse struct {
CreatedAt string `json:"created_at"`
Embeds []embedResponse `json:"embeds"`
Reactions []emojiGroup `json:"reactions"`
Poll *pollResponse `json:"poll,omitempty"`
}
// isMember checks whether the given user is a member of the given server.
@@ -549,6 +550,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
messages = h.attachEmbeds(r.Context(), messages)
messages = h.attachReactions(r.Context(), messages)
messages = h.attachPolls(r.Context(), messages)
// Reverse: query returns DESC (newest first), client expects ASC (oldest first).
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
@@ -672,6 +674,110 @@ func (h *Handler) attachReactions(ctx context.Context, messages []messageRespons
return messages
}
// attachPolls loads poll data for messages that have polls.
func (h *Handler) attachPolls(ctx context.Context, messages []messageResponse) []messageResponse {
if len(messages) == 0 {
return messages
}
msgIDs := make([]string, len(messages))
for i, m := range messages {
msgIDs[i] = m.ID
}
placeholders := make([]string, len(msgIDs))
args := make([]interface{}, len(msgIDs))
for i, id := range msgIDs {
placeholders[i] = fmt.Sprintf("$%d", i+1)
args[i] = id
}
query := fmt.Sprintf(`
SELECT p.id, p.message_id, p.question, p.created_at::text
FROM polls p
WHERE p.message_id IN (%s)
`, strings.Join(placeholders, ", "))
rows, err := h.db.QueryContext(ctx, query, args...)
if err != nil {
return messages
}
defer rows.Close()
msgMap := make(map[string]int)
for i, m := range messages {
msgMap[m.ID] = i
}
type pollRow struct {
ID string
MessageID string
Question string
CreatedAt string
}
pollRows := make([]pollRow, 0)
for rows.Next() {
var p pollRow
if err := rows.Scan(&p.ID, &p.MessageID, &p.Question, &p.CreatedAt); err != nil {
continue
}
pollRows = append(pollRows, p)
}
for _, pr := range pollRows {
idx, ok := msgMap[pr.MessageID]
if !ok {
continue
}
poll := &pollResponse{
ID: pr.ID,
MessageID: pr.MessageID,
Question: pr.Question,
CreatedAt: pr.CreatedAt,
Options: make([]pollOptionResponse, 0),
}
optRows, err := h.db.QueryContext(ctx, `
SELECT po.id, po.text, po.position, COUNT(pv.id) as votes
FROM poll_options po
LEFT JOIN poll_votes pv ON pv.option_id = po.id
WHERE po.poll_id = $1
GROUP BY po.id, po.text, po.position
ORDER BY po.position
`, pr.ID)
if err == nil {
for optRows.Next() {
var opt pollOptionResponse
if err := optRows.Scan(&opt.ID, &opt.Text, &opt.Position, &opt.Votes); err != nil {
continue
}
opt.Voters = []string{}
poll.Options = append(poll.Options, opt)
}
optRows.Close()
for i, opt := range poll.Options {
voterRows, err := h.db.QueryContext(ctx, `SELECT user_id FROM poll_votes WHERE option_id = $1`, opt.ID)
if err != nil {
continue
}
for voterRows.Next() {
var voterID string
if voterRows.Scan(&voterID) == nil {
poll.Options[i].Voters = append(poll.Options[i].Voters, voterID)
}
}
voterRows.Close()
}
}
messages[idx].Poll = poll
}
return messages
}
// Search searches messages in a channel using PostgreSQL full-text search.
func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
channelID := chi.URLParam(r, "channelID")