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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user