Files
hobokenchicken 40f8d193ff 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
2026-07-02 12:35:05 -04:00

325 lines
8.6 KiB
Go

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
}