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:
2026-07-02 13:11:52 -04:00
parent 3002626ac4
commit 5ca5e018ca
7 changed files with 525 additions and 1 deletions
+22
View File
@@ -534,5 +534,27 @@ CREATE TABLE IF NOT EXISTS poll_votes (
UNIQUE (option_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_poll_votes_option ON poll_votes(option_id);
-- Feature requests
CREATE TABLE IF NOT EXISTS feature_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
channel_id UUID REFERENCES channels(id) ON DELETE SET NULL,
author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'planned', 'done', 'rejected')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_feature_requests_server ON feature_requests(server_id, created_at DESC);
CREATE TABLE IF NOT EXISTS feature_request_votes (
feature_request_id UUID NOT NULL REFERENCES feature_requests(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (feature_request_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_feature_request_votes_fr ON feature_request_votes(feature_request_id);
`
+216
View File
@@ -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}`))
}