Files
hobokenchicken 5ca5e018ca feat: feature requests board with voting
- [FEATURES] tab next to PINNED in channel header
- Create, vote/unvote, filter by status (open/planned/done/rejected)
- Sort by vote count (most voted first)
- Status selector for moderation
- DB: feature_requests + feature_request_votes tables
- Backend: full CRUD + vote endpoints under /servers/{id}/feature-requests
2026-07-02 13:11:52 -04:00

217 lines
6.2 KiB
Go

package message
import (
"database/sql"
"encoding/json"
"net/http"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
"github.com/go-chi/chi/v5"
)
type FeatureRequestHandler struct {
db *sql.DB
}
func NewFeatureRequestHandler(db *sql.DB) *FeatureRequestHandler {
return &FeatureRequestHandler{db: db}
}
func (h *FeatureRequestHandler) RegisterRoutes(r chi.Router) {
r.Get("/", h.List)
r.Post("/", h.Create)
r.Post("/{id}/vote", h.Vote)
r.Delete("/{id}/vote", h.Unvote)
r.Patch("/{id}/status", h.UpdateStatus)
}
type featureRequestResponse struct {
ID string `json:"id"`
ServerID string `json:"server_id"`
ChannelID string `json:"channel_id"`
AuthorID string `json:"author_id"`
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
VoteCount int `json:"vote_count"`
Voters []string `json:"voters"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func (h *FeatureRequestHandler) List(w http.ResponseWriter, r *http.Request) {
serverID := chi.URLParam(r, "serverID")
if serverID == "" {
http.Error(w, `{"error":"serverID required"}`, http.StatusBadRequest)
return
}
rows, err := h.db.QueryContext(r.Context(), `
SELECT fr.id, fr.server_id, fr.channel_id, fr.author_id, fr.title, fr.description, fr.status,
fr.created_at, fr.updated_at,
COALESCE((SELECT count(*) FROM feature_request_votes frv WHERE frv.feature_request_id = fr.id), 0) as vote_count
FROM feature_requests fr
WHERE fr.server_id = $1
ORDER BY vote_count DESC, fr.created_at DESC
`, serverID)
if err != nil {
http.Error(w, `{"error":"query failed"}`, http.StatusInternalServerError)
return
}
defer rows.Close()
var results []featureRequestResponse
for rows.Next() {
var fr featureRequestResponse
if err := rows.Scan(&fr.ID, &fr.ServerID, &fr.ChannelID, &fr.AuthorID, &fr.Title, &fr.Description, &fr.Status, &fr.CreatedAt, &fr.UpdatedAt, &fr.VoteCount); err != nil {
continue
}
// Fetch voters
voterRows, _ := h.db.QueryContext(r.Context(), `
SELECT user_id FROM feature_request_votes WHERE feature_request_id = $1
`, fr.ID)
if voterRows != nil {
for voterRows.Next() {
var vid string
if voterRows.Scan(&vid) == nil {
fr.Voters = append(fr.Voters, vid)
}
}
voterRows.Close()
}
if fr.Voters == nil {
fr.Voters = []string{}
}
results = append(results, fr)
}
if results == nil {
results = []featureRequestResponse{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(results)
}
type createFeatureRequest struct {
Title string `json:"title"`
Description string `json:"description"`
ChannelID string `json:"channel_id"`
}
func (h *FeatureRequestHandler) Create(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
serverID := chi.URLParam(r, "serverID")
if serverID == "" {
http.Error(w, `{"error":"serverID required"}`, http.StatusBadRequest)
return
}
var req createFeatureRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
if req.Title == "" {
http.Error(w, `{"error":"title required"}`, http.StatusBadRequest)
return
}
if req.ChannelID == "" {
// Default to the first text channel in the server
h.db.QueryRowContext(r.Context(), `
SELECT id FROM channels WHERE server_id = $1 AND type = 'text' ORDER BY position LIMIT 1
`, serverID).Scan(&req.ChannelID)
}
var fr featureRequestResponse
err := h.db.QueryRowContext(r.Context(), `
INSERT INTO feature_requests (server_id, channel_id, author_id, title, description)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, server_id, channel_id, author_id, title, description, status, created_at, updated_at
`, serverID, req.ChannelID, userID, req.Title, req.Description).Scan(
&fr.ID, &fr.ServerID, &fr.ChannelID, &fr.AuthorID, &fr.Title, &fr.Description, &fr.Status, &fr.CreatedAt, &fr.UpdatedAt,
)
if err != nil {
http.Error(w, `{"error":"failed to create feature request"}`, http.StatusInternalServerError)
return
}
fr.VoteCount = 0
fr.Voters = []string{}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(fr)
}
func (h *FeatureRequestHandler) Vote(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
id := chi.URLParam(r, "id")
_, err := h.db.ExecContext(r.Context(), `
INSERT INTO feature_request_votes (feature_request_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING
`, id, userID)
if err != nil {
http.Error(w, `{"error":"vote failed"}`, http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok":true}`))
}
func (h *FeatureRequestHandler) Unvote(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
id := chi.URLParam(r, "id")
h.db.ExecContext(r.Context(), `
DELETE FROM feature_request_votes WHERE feature_request_id = $1 AND user_id = $2
`, id, userID)
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok":true}`))
}
func (h *FeatureRequestHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var req struct {
Status string `json:"status"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
validStatuses := map[string]bool{"open": true, "planned": true, "done": true, "rejected": true}
if !validStatuses[req.Status] {
http.Error(w, `{"error":"invalid status"}`, http.StatusBadRequest)
return
}
_, err := h.db.ExecContext(r.Context(), `
UPDATE feature_requests SET status = $1, updated_at = NOW() WHERE id = $2
`, req.Status, id)
if err != nil {
http.Error(w, `{"error":"update failed"}`, http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok":true}`))
}