5ca5e018ca
- [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
217 lines
6.2 KiB
Go
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}`))
|
|
}
|