feat(phase8.1): server groups — table, CRUD handler, collapsible channel sections

This commit is contained in:
2026-06-30 12:50:53 -04:00
parent 6d66d7775f
commit d663638387
7 changed files with 587 additions and 88 deletions
+30 -26
View File
@@ -38,21 +38,23 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
}
type createChannelRequest struct {
Name string `json:"name"`
Type string `json:"type"`
Category string `json:"category"`
Position *int `json:"position"`
Name string `json:"name"`
Type string `json:"type"`
Category string `json:"category"`
Position *int `json:"position"`
GroupID *string `json:"group_id"`
}
type channelResponse struct {
ID string `json:"id"`
ServerID string `json:"server_id"`
Name string `json:"name"`
Type string `json:"type"`
Category string `json:"category"`
Position int `json:"position"`
SlowmodeSeconds int `json:"slowmode_seconds"`
CreatedAt string `json:"created_at"`
ID string `json:"id"`
ServerID string `json:"server_id"`
Name string `json:"name"`
Type string `json:"type"`
Category string `json:"category"`
Position int `json:"position"`
SlowmodeSeconds int `json:"slowmode_seconds"`
GroupID *string `json:"group_id"`
CreatedAt string `json:"created_at"`
}
// isMember checks whether the given user is a member of the given server.
@@ -133,11 +135,11 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
var ch channelResponse
err = h.db.QueryRowContext(r.Context(), `
INSERT INTO channels (server_id, name, type, category, position, slowmode_seconds)
VALUES ($1, $2, $3, $4, $5, 0)
RETURNING id, server_id, name, type, category, position, slowmode_seconds, created_at
`, serverID, req.Name, channelType, category, position).Scan(
&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.SlowmodeSeconds, &ch.CreatedAt,
INSERT INTO channels (server_id, name, type, category, position, slowmode_seconds, group_id)
VALUES ($1, $2, $3, $4, $5, 0, $6)
RETURNING id, server_id, name, type, category, position, slowmode_seconds, group_id, created_at
`, serverID, req.Name, channelType, category, position, req.GroupID).Scan(
&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.SlowmodeSeconds, &ch.GroupID, &ch.CreatedAt,
)
if err != nil {
http.Error(w, `{"error":"failed to create channel (name may already exist in this server)"}`, http.StatusConflict)
@@ -184,7 +186,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
}
rows, err := h.db.QueryContext(r.Context(), `
SELECT id, server_id, name, type, category, position, slowmode_seconds, created_at
SELECT id, server_id, name, type, category, position, slowmode_seconds, group_id, created_at
FROM channels
WHERE server_id = $1
ORDER BY position, name
@@ -198,7 +200,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
channels := make([]channelResponse, 0)
for rows.Next() {
var ch channelResponse
if err := rows.Scan(&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.SlowmodeSeconds, &ch.CreatedAt); err != nil {
if err := rows.Scan(&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.SlowmodeSeconds, &ch.GroupID, &ch.CreatedAt); err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
@@ -235,9 +237,9 @@ func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
var ch channelResponse
err := h.db.QueryRowContext(r.Context(), `
SELECT id, server_id, name, type, category, position, slowmode_seconds, created_at
SELECT id, server_id, name, type, category, position, slowmode_seconds, group_id, created_at
FROM channels WHERE id = $1
`, channelID).Scan(&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.SlowmodeSeconds, &ch.CreatedAt)
`, channelID).Scan(&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.SlowmodeSeconds, &ch.GroupID, &ch.CreatedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound)
@@ -267,6 +269,7 @@ type updateChannelRequest struct {
Category *string `json:"category"`
Position *int `json:"position"`
SlowmodeSeconds *int `json:"slowmode_seconds"`
GroupID *string `json:"group_id"`
}
// @Summary Update a channel
@@ -297,7 +300,7 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
if req.Name == nil && req.Type == nil && req.Category == nil && req.Position == nil && req.SlowmodeSeconds == nil {
if req.Name == nil && req.Type == nil && req.Category == nil && req.Position == nil && req.SlowmodeSeconds == nil && req.GroupID == nil {
http.Error(w, `{"error":"nothing to update"}`, http.StatusBadRequest)
return
}
@@ -341,11 +344,12 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
type = COALESCE($2, type),
category = COALESCE($3, category),
position = COALESCE($4, position),
slowmode_seconds = COALESCE($5, slowmode_seconds)
slowmode_seconds = COALESCE($5, slowmode_seconds),
group_id = $7
WHERE id = $6
RETURNING id, server_id, name, type, category, position, slowmode_seconds, created_at
`, req.Name, req.Type, req.Category, req.Position, req.SlowmodeSeconds, channelID).Scan(
&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.SlowmodeSeconds, &ch.CreatedAt,
RETURNING id, server_id, name, type, category, position, slowmode_seconds, group_id, created_at
`, req.Name, req.Type, req.Category, req.Position, req.SlowmodeSeconds, channelID, req.GroupID).Scan(
&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.SlowmodeSeconds, &ch.GroupID, &ch.CreatedAt,
)
if err != nil {
http.Error(w, `{"error":"failed to update channel"}`, http.StatusInternalServerError)
+13
View File
@@ -479,4 +479,17 @@ DROP TRIGGER IF EXISTS messages_search_trigger ON messages;
CREATE TRIGGER messages_search_trigger
BEFORE INSERT OR UPDATE ON messages
FOR EACH ROW EXECUTE FUNCTION messages_search_update();
-- Server groups (sub-server sections for organizing channels)
CREATE TABLE IF NOT EXISTS server_groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (server_id, name)
);
CREATE INDEX IF NOT EXISTS idx_server_groups_server ON server_groups(server_id);
ALTER TABLE channels ADD COLUMN IF NOT EXISTS group_id UUID REFERENCES server_groups(id) ON DELETE SET NULL;
`
+309
View File
@@ -0,0 +1,309 @@
package servergroup
import (
"database/sql"
"encoding/json"
"net/http"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
"github.com/go-chi/chi/v5"
)
type Handler struct {
db *sql.DB
}
func NewHandler(db *sql.DB) *Handler {
return &Handler{db: db}
}
func (h *Handler) RegisterRoutes(r chi.Router) {
r.Post("/", h.Create)
r.Get("/", h.List)
r.Get("/{groupID}", h.Get)
r.Patch("/{groupID}", h.Update)
r.Delete("/{groupID}", h.Delete)
}
type groupResponse struct {
ID string `json:"id"`
ServerID string `json:"server_id"`
Name string `json:"name"`
Position int `json:"position"`
CreatedAt string `json:"created_at"`
}
type createGroupRequest struct {
Name string `json:"name"`
Position *int `json:"position"`
}
type updateGroupRequest struct {
Name *string `json:"name"`
Position *int `json:"position"`
}
// @Summary Create a server group
// @Description Create a new channel group within a server
// @Tags server_groups
// @Accept json
// @Produce json
// @Security SessionAuth
// @Param serverID path string true "Server ID"
// @Param body body createGroupRequest true "Group data"
// @Success 201 {object} groupResponse
// @Failure 400 {object} map[string]string
// @Router /servers/{serverID}/groups [post]
func (h *Handler) 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")
var req createGroupRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
if req.Name == "" {
http.Error(w, `{"error":"name is required"}`, http.StatusBadRequest)
return
}
// Verify ownership or MANAGE_CHANNELS
var ownerID string
err := h.db.QueryRowContext(r.Context(), `SELECT owner_id FROM servers WHERE id = $1`, serverID).Scan(&ownerID)
if err != nil {
http.Error(w, `{"error":"server not found"}`, http.StatusNotFound)
return
}
if ownerID != userID {
// Check permission
var hasPerm bool
err = h.db.QueryRowContext(r.Context(), `
SELECT EXISTS (
SELECT 1 FROM role_permissions rp
JOIN member_roles mr ON mr.role_id = rp.role_id
WHERE mr.user_id = $1 AND mr.server_id = $2 AND rp.permission = 'manage_channels'
)
`, userID, serverID).Scan(&hasPerm)
if err != nil || !hasPerm {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return
}
}
var position int
if req.Position == nil {
// Auto-assign position (max + 1)
err = h.db.QueryRowContext(r.Context(), `SELECT COALESCE(MAX(position), -1) + 1 FROM server_groups WHERE server_id = $1`, serverID).Scan(&position)
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
} else {
position = *req.Position
}
var grp groupResponse
err = h.db.QueryRowContext(r.Context(), `
INSERT INTO server_groups (server_id, name, position)
VALUES ($1, $2, $3)
RETURNING id, server_id, name, position, created_at
`, serverID, req.Name, position).Scan(&grp.ID, &grp.ServerID, &grp.Name, &grp.Position, &grp.CreatedAt)
if err != nil {
http.Error(w, `{"error":"failed to create group (name may already exist)"}`, http.StatusConflict)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(grp)
}
// @Summary List server groups
// @Description List all channel groups for a server, ordered by position
// @Tags server_groups
// @Produce json
// @Security SessionAuth
// @Param serverID path string true "Server ID"
// @Success 200 {array} groupResponse
// @Router /servers/{serverID}/groups [get]
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
serverID := chi.URLParam(r, "serverID")
rows, err := h.db.QueryContext(r.Context(), `
SELECT id, server_id, name, position, created_at
FROM server_groups
WHERE server_id = $1
ORDER BY position, name
`, serverID)
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
defer rows.Close()
groups := make([]groupResponse, 0)
for rows.Next() {
var grp groupResponse
if err := rows.Scan(&grp.ID, &grp.ServerID, &grp.Name, &grp.Position, &grp.CreatedAt); err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
groups = append(groups, grp)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(groups)
}
// @Summary Get a server group
// @Tags server_groups
// @Produce json
// @Security SessionAuth
// @Param serverID path string true "Server ID"
// @Param groupID path string true "Group ID"
// @Success 200 {object} groupResponse
// @Router /servers/{serverID}/groups/{groupID} [get]
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
groupID := chi.URLParam(r, "groupID")
var grp groupResponse
err := h.db.QueryRowContext(r.Context(), `
SELECT id, server_id, name, position, created_at
FROM server_groups WHERE id = $1
`, groupID).Scan(&grp.ID, &grp.ServerID, &grp.Name, &grp.Position, &grp.CreatedAt)
if err != nil {
http.Error(w, `{"error":"group not found"}`, http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(grp)
}
// @Summary Update a server group
// @Tags server_groups
// @Accept json
// @Produce json
// @Security SessionAuth
// @Param serverID path string true "Server ID"
// @Param groupID path string true "Group ID"
// @Param body body updateGroupRequest true "Group update data"
// @Success 200 {object} groupResponse
// @Router /servers/{serverID}/groups/{groupID} [patch]
func (h *Handler) Update(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")
groupID := chi.URLParam(r, "groupID")
var req updateGroupRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
if req.Name == nil && req.Position == nil {
http.Error(w, `{"error":"nothing to update"}`, http.StatusBadRequest)
return
}
var ownerID string
err := h.db.QueryRowContext(r.Context(), `SELECT owner_id FROM servers WHERE id = $1`, serverID).Scan(&ownerID)
if err != nil {
http.Error(w, `{"error":"server not found"}`, http.StatusNotFound)
return
}
if ownerID != userID {
var hasPerm bool
err = h.db.QueryRowContext(r.Context(), `
SELECT EXISTS (
SELECT 1 FROM role_permissions rp
JOIN member_roles mr ON mr.role_id = rp.role_id
WHERE mr.user_id = $1 AND mr.server_id = $2 AND rp.permission = 'manage_channels'
)
`, userID, serverID).Scan(&hasPerm)
if err != nil || !hasPerm {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return
}
}
var grp groupResponse
err = h.db.QueryRowContext(r.Context(), `
UPDATE server_groups
SET name = COALESCE($1, name),
position = COALESCE($2, position)
WHERE id = $3 AND server_id = $4
RETURNING id, server_id, name, position, created_at
`, req.Name, req.Position, groupID, serverID).Scan(&grp.ID, &grp.ServerID, &grp.Name, &grp.Position, &grp.CreatedAt)
if err != nil {
http.Error(w, `{"error":"group not found"}`, http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(grp)
}
// @Summary Delete a server group
// @Tags server_groups
// @Security SessionAuth
// @Param serverID path string true "Server ID"
// @Param groupID path string true "Group ID"
// @Success 204
// @Router /servers/{serverID}/groups/{groupID} [delete]
func (h *Handler) Delete(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")
groupID := chi.URLParam(r, "groupID")
// Only owner or manage_channels can delete groups
var ownerID string
err := h.db.QueryRowContext(r.Context(), `SELECT owner_id FROM servers WHERE id = $1`, serverID).Scan(&ownerID)
if err != nil {
http.Error(w, `{"error":"server not found"}`, http.StatusNotFound)
return
}
if ownerID != userID {
var hasPerm bool
err = h.db.QueryRowContext(r.Context(), `
SELECT EXISTS (
SELECT 1 FROM role_permissions rp
JOIN member_roles mr ON mr.role_id = rp.role_id
WHERE mr.user_id = $1 AND mr.server_id = $2 AND rp.permission = 'manage_channels'
)
`, userID, serverID).Scan(&hasPerm)
if err != nil || !hasPerm {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return
}
}
// Channels in this group get group_id set to NULL (via ON DELETE SET NULL)
result, err := h.db.ExecContext(r.Context(), `DELETE FROM server_groups WHERE id = $1 AND server_id = $2`, groupID, serverID)
if err != nil {
http.Error(w, `{"error":"failed to delete group"}`, http.StatusInternalServerError)
return
}
rows, _ := result.RowsAffected()
if rows == 0 {
http.Error(w, `{"error":"group not found"}`, http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
}