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:
@@ -509,5 +509,30 @@ CREATE TABLE IF NOT EXISTS server_groups (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_server_groups_server ON server_groups(server_id);
|
||||
ALTER TABLE channels ADD COLUMN IF NOT EXISTS group_id UUID REFERENCES server_groups(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS polls (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||
question TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_polls_message ON polls(message_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS poll_options (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
poll_id UUID NOT NULL REFERENCES polls(id) ON DELETE CASCADE,
|
||||
text TEXT NOT NULL,
|
||||
position INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_poll_options_poll ON poll_options(poll_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS poll_votes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
option_id UUID NOT NULL REFERENCES poll_options(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (option_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_poll_votes_option ON poll_votes(option_id);
|
||||
`
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
package message
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type PollHandler struct {
|
||||
db *sql.DB
|
||||
hub *gateway.Hub
|
||||
checker *permissions.Checker
|
||||
}
|
||||
|
||||
func NewPollHandler(db *sql.DB, hub *gateway.Hub, checker *permissions.Checker) *PollHandler {
|
||||
return &PollHandler{db: db, hub: hub, checker: checker}
|
||||
}
|
||||
|
||||
func (ph *PollHandler) RegisterRoutes(r chi.Router) {
|
||||
r.Post("/", ph.Create)
|
||||
r.Get("/{pollID}", ph.Get)
|
||||
r.Post("/{pollID}/vote", ph.Vote)
|
||||
}
|
||||
|
||||
type createPollRequest struct {
|
||||
ChannelID string `json:"channel_id"`
|
||||
Question string `json:"question"`
|
||||
Options []string `json:"options"`
|
||||
}
|
||||
|
||||
type pollOptionResponse struct {
|
||||
ID string `json:"id"`
|
||||
Text string `json:"text"`
|
||||
Position int `json:"position"`
|
||||
Votes int `json:"votes"`
|
||||
Voters []string `json:"voters"`
|
||||
}
|
||||
|
||||
type pollResponse struct {
|
||||
ID string `json:"id"`
|
||||
MessageID string `json:"message_id"`
|
||||
Question string `json:"question"`
|
||||
Options []pollOptionResponse `json:"options"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func (ph *PollHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req createPollRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Question == "" || len(req.Options) < 2 || len(req.Options) > 10 {
|
||||
http.Error(w, `{"error":"question required, 2-10 options"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var serverID string
|
||||
err := ph.db.QueryRowContext(r.Context(), `
|
||||
SELECT server_id FROM channels WHERE id = $1
|
||||
`, req.ChannelID).Scan(&serverID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := ph.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var msgID string
|
||||
err = tx.QueryRowContext(r.Context(), `
|
||||
INSERT INTO messages (channel_id, author_id, content)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id
|
||||
`, req.ChannelID, userID, req.Question).Scan(&msgID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to create message"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var pollID string
|
||||
err = tx.QueryRowContext(r.Context(), `
|
||||
INSERT INTO polls (message_id, question)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id
|
||||
`, msgID, req.Question).Scan(&pollID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to create poll"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
options := make([]pollOptionResponse, len(req.Options))
|
||||
for i, text := range req.Options {
|
||||
var optID string
|
||||
err = tx.QueryRowContext(r.Context(), `
|
||||
INSERT INTO poll_options (poll_id, text, position)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id
|
||||
`, pollID, text, i).Scan(&optID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to create option"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
options[i] = pollOptionResponse{
|
||||
ID: optID,
|
||||
Text: text,
|
||||
Position: i,
|
||||
Votes: 0,
|
||||
Voters: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var authorName string
|
||||
var displayName sql.NullString
|
||||
_ = ph.db.QueryRowContext(r.Context(), `
|
||||
SELECT username, display_name FROM users WHERE id = $1
|
||||
`, userID).Scan(&authorName, &displayName)
|
||||
|
||||
var createdAt string
|
||||
_ = ph.db.QueryRowContext(r.Context(), `SELECT created_at::text FROM messages WHERE id = $1`, msgID).Scan(&createdAt)
|
||||
|
||||
poll := pollResponse{
|
||||
ID: pollID,
|
||||
MessageID: msgID,
|
||||
Question: req.Question,
|
||||
Options: options,
|
||||
CreatedAt: createdAt,
|
||||
}
|
||||
|
||||
broadcastMsg := map[string]interface{}{
|
||||
"id": msgID,
|
||||
"channel_id": req.ChannelID,
|
||||
"author_id": userID,
|
||||
"author_username": authorName,
|
||||
"author_display_name": displayName.String,
|
||||
"content": req.Question,
|
||||
"pinned": false,
|
||||
"created_at": createdAt,
|
||||
"reactions": []interface{}{},
|
||||
"embeds": []interface{}{},
|
||||
"poll": poll,
|
||||
}
|
||||
ph.hub.BroadcastToServer(serverID, gateway.Event{
|
||||
Type: gateway.EventMessageCreate,
|
||||
Data: broadcastMsg,
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(poll)
|
||||
}
|
||||
|
||||
func (ph *PollHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
pollID := chi.URLParam(r, "pollID")
|
||||
|
||||
poll, err := ph.fetchPoll(r.Context(), pollID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"poll not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(poll)
|
||||
}
|
||||
|
||||
type voteRequest struct {
|
||||
OptionID string `json:"option_id"`
|
||||
}
|
||||
|
||||
func (ph *PollHandler) Vote(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
pollID := chi.URLParam(r, "pollID")
|
||||
|
||||
var req voteRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var optPollID string
|
||||
err := ph.db.QueryRowContext(r.Context(), `
|
||||
SELECT poll_id FROM poll_options WHERE id = $1
|
||||
`, req.OptionID).Scan(&optPollID)
|
||||
if err != nil || optPollID != pollID {
|
||||
http.Error(w, `{"error":"invalid option"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := ph.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.ExecContext(r.Context(), `
|
||||
DELETE FROM poll_votes
|
||||
WHERE user_id = $1
|
||||
AND option_id IN (SELECT id FROM poll_options WHERE poll_id = $2)
|
||||
`, userID, pollID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(r.Context(), `
|
||||
INSERT INTO poll_votes (option_id, user_id)
|
||||
VALUES ($1, $2)
|
||||
`, req.OptionID, userID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Get server for broadcast
|
||||
var msgID, channelID, serverID string
|
||||
_ = ph.db.QueryRowContext(r.Context(), `
|
||||
SELECT p.message_id, m.channel_id, c.server_id
|
||||
FROM polls p
|
||||
JOIN messages m ON m.id = p.message_id
|
||||
JOIN channels c ON c.id = m.channel_id
|
||||
WHERE p.id = $1
|
||||
`, pollID).Scan(&msgID, &channelID, &serverID)
|
||||
|
||||
pollResp, _ := ph.fetchPoll(r.Context(), pollID)
|
||||
if pollResp != nil {
|
||||
ph.hub.BroadcastToServer(serverID, gateway.Event{
|
||||
Type: "POLL_UPDATE",
|
||||
Data: map[string]interface{}{
|
||||
"poll": pollResp,
|
||||
"message_id": msgID,
|
||||
"channel_id": channelID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (ph *PollHandler) fetchPoll(ctx context.Context, pollID string) (*pollResponse, error) {
|
||||
var poll pollResponse
|
||||
var createdAt sql.NullString
|
||||
err := ph.db.QueryRowContext(ctx, `
|
||||
SELECT id, message_id, question, created_at::text FROM polls WHERE id = $1
|
||||
`, pollID).Scan(&poll.ID, &poll.MessageID, &poll.Question, &createdAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if createdAt.Valid {
|
||||
poll.CreatedAt = createdAt.String
|
||||
}
|
||||
|
||||
rows, err := ph.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
|
||||
`, pollID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
poll.Options = make([]pollOptionResponse, 0)
|
||||
for rows.Next() {
|
||||
var opt pollOptionResponse
|
||||
if err := rows.Scan(&opt.ID, &opt.Text, &opt.Position, &opt.Votes); err != nil {
|
||||
continue
|
||||
}
|
||||
opt.Voters = []string{}
|
||||
poll.Options = append(poll.Options, opt)
|
||||
}
|
||||
|
||||
for i, opt := range poll.Options {
|
||||
voterRows, err := ph.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()
|
||||
}
|
||||
|
||||
return &poll, nil
|
||||
}
|
||||
Reference in New Issue
Block a user