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 }