package server import ( "context" "database/sql" "encoding/json" "errors" "net/http" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions" "github.com/go-chi/chi/v5" ) // RoleHandler manages role CRUD and member-role assignment. type RoleHandler struct { db *sql.DB checker *permissions.Checker } // NewRoleHandler creates a new RoleHandler. func NewRoleHandler(db *sql.DB) *RoleHandler { return &RoleHandler{ db: db, checker: permissions.NewChecker(db), } } // RegisterRoleRoutes mounts role and member-role endpoints on the given router. func (h *RoleHandler) RegisterRoleRoutes(r chi.Router) { r.Route("/servers/{serverID}/roles", func(r chi.Router) { r.Use(middleware.RequireAuth) r.Post("/", h.CreateRole) r.Get("/", h.ListRoles) r.Patch("/{roleID}", h.UpdateRole) r.Delete("/{roleID}", h.DeleteRole) }) r.Route("/servers/{serverID}/members/{userID}/roles", func(r chi.Router) { r.Use(middleware.RequireAuth) r.Put("/", h.SetMemberRoles) r.Get("/", h.GetMemberRoles) }) } // --------------------------------------------------------------------------- // Request / response types // --------------------------------------------------------------------------- type createRoleRequest struct { Name string `json:"name"` Color string `json:"color"` Permissions int64 `json:"permissions"` Position int `json:"position"` } type updateRoleRequest struct { Name *string `json:"name"` Color *string `json:"color"` Permissions *int64 `json:"permissions"` Position *int `json:"position"` } type roleResponse struct { ID string `json:"id"` ServerID string `json:"server_id"` Name string `json:"name"` Color *string `json:"color"` Permissions int64 `json:"permissions"` Position int `json:"position"` IsDefault bool `json:"is_default"` CreatedAt string `json:"created_at"` } type setMemberRolesRequest struct { RoleIDs []string `json:"role_ids"` } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- // requireManageServer checks that the caller has MANAGE_SERVER or MANAGE_ROLES permission. // Returns false (and writes the error response) if denied. func (h *RoleHandler) requireManageServer(w http.ResponseWriter, r *http.Request, serverID string) bool { userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) return false } allowed, err := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_ROLES) if err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return false } if !allowed { allowed, err = h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_SERVER) if err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return false } } if !allowed { http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) return false } return true } // scanRole scans a single role row into a roleResponse. func scanRole(row interface{ Scan(dest ...any) error }) (roleResponse, error) { var role roleResponse err := row.Scan( &role.ID, &role.ServerID, &role.Name, &role.Color, &role.Permissions, &role.Position, &role.IsDefault, &role.CreatedAt, ) return role, err } // --------------------------------------------------------------------------- // Handlers // --------------------------------------------------------------------------- // CreateRole handles POST /servers/{serverID}/roles. func (h *RoleHandler) CreateRole(w http.ResponseWriter, r *http.Request) { serverID := chi.URLParam(r, "serverID") if !h.requireManageServer(w, r, serverID) { return } var req createRoleRequest 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 } role, err := scanRole(h.db.QueryRowContext(r.Context(), ` INSERT INTO roles (server_id, name, color, permissions, position) VALUES ($1, $2, $3, $4, $5) RETURNING id, server_id, name, color, permissions, position, is_default, created_at `, serverID, req.Name, req.Color, req.Permissions, req.Position)) if err != nil { http.Error(w, `{"error":"failed to create role"}`, http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(role) } // ListRoles handles GET /servers/{serverID}/roles. func (h *RoleHandler) ListRoles(w http.ResponseWriter, r *http.Request) { serverID := chi.URLParam(r, "serverID") rows, err := h.db.QueryContext(r.Context(), ` SELECT id, server_id, name, color, permissions, position, is_default, created_at FROM roles WHERE server_id = $1 ORDER BY position DESC, name `, serverID) if err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } defer rows.Close() roles := make([]roleResponse, 0) for rows.Next() { role, err := scanRole(rows) if err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } roles = append(roles, role) } if err := rows.Err(); err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(roles) } // UpdateRole handles PATCH /servers/{serverID}/roles/{roleID}. func (h *RoleHandler) UpdateRole(w http.ResponseWriter, r *http.Request) { serverID := chi.URLParam(r, "serverID") roleID := chi.URLParam(r, "roleID") if !h.requireManageServer(w, r, serverID) { return } var req updateRoleRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) return } if req.Name == nil && req.Color == nil && req.Permissions == nil && req.Position == nil { http.Error(w, `{"error":"nothing to update"}`, http.StatusBadRequest) return } role, err := scanRole(h.db.QueryRowContext(r.Context(), ` UPDATE roles SET name = COALESCE($1, name), color = COALESCE($2, color), permissions = COALESCE($3, permissions), position = COALESCE($4, position) WHERE id = $5 AND server_id = $6 RETURNING id, server_id, name, color, permissions, position, is_default, created_at `, req.Name, req.Color, req.Permissions, req.Position, roleID, serverID)) if err != nil { if errors.Is(err, sql.ErrNoRows) { http.Error(w, `{"error":"role not found"}`, http.StatusNotFound) return } http.Error(w, `{"error":"failed to update role"}`, http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(role) } // DeleteRole handles DELETE /servers/{serverID}/roles/{roleID}. func (h *RoleHandler) DeleteRole(w http.ResponseWriter, r *http.Request) { serverID := chi.URLParam(r, "serverID") roleID := chi.URLParam(r, "roleID") if !h.requireManageServer(w, r, serverID) { return } // Prevent deleting the @everyone (default) role. var isDefault bool err := h.db.QueryRowContext(r.Context(), ` SELECT is_default FROM roles WHERE id = $1 AND server_id = $2 `, roleID, serverID).Scan(&isDefault) if err != nil { if errors.Is(err, sql.ErrNoRows) { http.Error(w, `{"error":"role not found"}`, http.StatusNotFound) return } http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } if isDefault { http.Error(w, `{"error":"cannot delete the default @everyone role"}`, http.StatusBadRequest) return } _, err = h.db.ExecContext(r.Context(), ` DELETE FROM roles WHERE id = $1 AND server_id = $2 `, roleID, serverID) if err != nil { http.Error(w, `{"error":"failed to delete role"}`, http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // SetMemberRoles handles PUT /servers/{serverID}/members/{userID}/roles. func (h *RoleHandler) SetMemberRoles(w http.ResponseWriter, r *http.Request) { serverID := chi.URLParam(r, "serverID") targetUserID := chi.URLParam(r, "userID") if !h.requireManageServer(w, r, serverID) { return } // Verify target is a member of the server. var exists bool err := h.db.QueryRowContext(r.Context(), ` SELECT EXISTS(SELECT 1 FROM members WHERE user_id = $1 AND server_id = $2) `, targetUserID, serverID).Scan(&exists) if err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } if !exists { http.Error(w, `{"error":"user is not a member of this server"}`, http.StatusNotFound) return } var req setMemberRolesRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) return } tx, err := h.db.BeginTx(r.Context(), nil) if err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } defer tx.Rollback() // Remove existing roles for this member in this server. _, err = tx.ExecContext(r.Context(), ` DELETE FROM member_roles WHERE user_id = $1 AND server_id = $2 `, targetUserID, serverID) if err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } // Insert new roles (skip the default role — it's implicit). for _, roleID := range req.RoleIDs { _, err = tx.ExecContext(r.Context(), ` INSERT INTO member_roles (user_id, server_id, role_id) VALUES ($1, $2, $3) `, targetUserID, serverID, roleID) if err != nil { http.Error(w, `{"error":"failed to assign role"}`, http.StatusInternalServerError) return } } if err := tx.Commit(); err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // GetMemberRoles handles GET /servers/{serverID}/members/{userID}/roles. func (h *RoleHandler) GetMemberRoles(w http.ResponseWriter, r *http.Request) { serverID := chi.URLParam(r, "serverID") targetUserID := chi.URLParam(r, "userID") rows, err := h.db.QueryContext(r.Context(), ` SELECT r.id, r.server_id, r.name, r.color, r.permissions, r.position, r.is_default, r.created_at FROM roles r INNER JOIN member_roles mr ON mr.role_id = r.id WHERE mr.user_id = $1 AND mr.server_id = $2 ORDER BY r.position DESC, r.name `, targetUserID, serverID) if err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } defer rows.Close() roles := make([]roleResponse, 0) for rows.Next() { role, err := scanRole(rows) if err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } roles = append(roles, role) } if err := rows.Err(); err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(roles) } // SeedDefaultRole inserts the @everyone, Admin, and Moderator roles for a newly created server. // Call this after creating a server (e.g. from the server creation handler). func SeedDefaultRole(ctx context.Context, db *sql.DB, serverID string) error { // @everyone _, err := db.ExecContext(ctx, ` INSERT INTO roles (server_id, name, permissions, position, is_default, color) VALUES ($1, '@everyone', $2, 0, TRUE, NULL) `, serverID, permissions.DefaultEveryonePermissions) if err != nil { return err } // Admin adminPerms := permissions.ADMINISTRATOR _, err = db.ExecContext(ctx, ` INSERT INTO roles (server_id, name, permissions, position, is_default, color) VALUES ($1, 'Admin', $2, 99, FALSE, '#e06c75') `, serverID, adminPerms) if err != nil { return err } // Moderator modPerms := permissions.VIEW_CHANNEL | permissions.SEND_MESSAGES | permissions.MANAGE_MESSAGES | permissions.KICK_MEMBERS | permissions.BAN_MEMBERS | permissions.MUTE_MEMBERS | permissions.CONNECT_VOICE | permissions.SPEAK_VOICE _, err = db.ExecContext(ctx, ` INSERT INTO roles (server_id, name, permissions, position, is_default, color) VALUES ($1, 'Moderator', $2, 50, FALSE, '#61afef') `, serverID, modPerms) return err }