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
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
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}`))
|
||||
}
|
||||
Reference in New Issue
Block a user