diff --git a/cmd/server/main.go b/cmd/server/main.go index da52bb7..b11d655 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -232,6 +232,12 @@ func main() { pollHandler.RegisterRoutes(r) }) + // Feature requests + frHandler := message.NewFeatureRequestHandler(database.DB) + r.Route("/servers/{serverID}/feature-requests", func(r chi.Router) { + frHandler.RegisterRoutes(r) + }) + // Per-channel notification settings r.Route("/channels/{channelID}/notifications", func(r chi.Router) { notification.NewHandler(database.DB, logger).RegisterRoutes(r) diff --git a/internal/db/db.go b/internal/db/db.go index 47602f5..b23f228 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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); ` diff --git a/internal/message/featurerequest.go b/internal/message/featurerequest.go new file mode 100644 index 0000000..db76a67 --- /dev/null +++ b/internal/message/featurerequest.go @@ -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}`)) +} diff --git a/web/src/components/ChatArea.tsx b/web/src/components/ChatArea.tsx index 258616a..fc817e2 100644 --- a/web/src/components/ChatArea.tsx +++ b/web/src/components/ChatArea.tsx @@ -18,6 +18,7 @@ import { ThreadListPanel } from "./ThreadListPanel.tsx"; import { ThreadPanel } from "./ThreadPanel.tsx"; import { useContextMenu } from "./ContextMenu.tsx"; import { PinnedMessages } from "./PinnedMessages.tsx"; +import { FeatureRequestsPanel } from "./FeatureRequestsPanel.tsx"; import { ReactionBar } from "./ReactionBar.tsx"; import { EmojiPicker } from "./EmojiPicker.tsx"; import { ReplyBar } from "./ReplyBar.tsx"; @@ -308,6 +309,7 @@ export function ChatArea() { const markRead = useReadStatesStore((s) => s.markRead); const [showPinned, setShowPinned] = useState(false); + const [showFeatureRequests, setShowFeatureRequests] = useState(false); const [activeReactionMessageId, setActiveReactionMessageId] = useState(null); const [replyToMessage, setReplyToMessage] = useState(null); const pinMessage = useMessageStore((s) => s.pinMessage); @@ -662,6 +664,7 @@ export function ChatArea() { + )} @@ -739,6 +754,13 @@ export function ChatArea() { onJump={handleJumpToMessage} /> )} + {showFeatureRequests && activeServerId && ( + setShowFeatureRequests(false)} + /> + )} {showThreads && activeChannelId && activeChannel?.type !== 'forum' && ( void; +} + +const STATUS_LABELS: Record = { + open: "OPEN", + planned: "PLANNED", + done: "DONE", + rejected: "REJECTED", +}; + +const STATUS_COLORS: Record = { + open: "text-gb-green", + planned: "text-gb-yellow", + done: "text-gb-aqua", + rejected: "text-gb-red", +}; + +export function FeatureRequestsPanel({ serverId, channelId, onClose }: FeatureRequestsPanelProps) { + const requests = useFeatureRequestStore((s) => s.requestsByServer[serverId] || []); + const fetchRequests = useFeatureRequestStore((s) => s.fetchRequests); + const createRequest = useFeatureRequestStore((s) => s.createRequest); + const vote = useFeatureRequestStore((s) => s.vote); + const unvote = useFeatureRequestStore((s) => s.unvote); + const updateStatus = useFeatureRequestStore((s) => s.updateStatus); + const currentUser = useAuthStore((s) => s.user); + + const [showCreate, setShowCreate] = useState(false); + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [filter, setFilter] = useState("all"); + + useEffect(() => { + fetchRequests(serverId); + }, [serverId, fetchRequests]); + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [onClose]); + + const handleCreate = async () => { + if (!title.trim()) return; + setSubmitting(true); + try { + await createRequest(serverId, title.trim(), description.trim(), channelId); + setTitle(""); + setDescription(""); + setShowCreate(false); + } catch { + // silent + } finally { + setSubmitting(false); + } + }; + + const handleVote = async (fr: FeatureRequest) => { + const hasVoted = currentUser ? fr.voters.includes(currentUser.id) : false; + if (hasVoted) { + await unvote(serverId, fr.id); + } else { + await vote(serverId, fr.id); + } + }; + + const filtered = filter === "all" ? requests : requests.filter((r) => r.status === filter); + + return ( +
+
+ FEATURE REQUESTS ({requests.length}) +
+ + +
+
+ + {/* Filter tabs */} +
+ {["all", "open", "planned", "done", "rejected"].map((s) => ( + + ))} +
+ + {/* Create form */} + {showCreate && ( +
+ setTitle(e.target.value)} + placeholder="Feature title..." + className="w-full bg-gb-bg-s border border-gb-bg-t text-gb-fg text-xs font-mono px-2 py-1 mb-1 outline-none focus:border-gb-orange" + maxLength={255} + /> +