387 lines
11 KiB
Go
387 lines
11 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"math"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"git.gobha.me/xcaliber/chat-switchboard/database"
|
|
)
|
|
|
|
// ── Types ───────────────────────────────────
|
|
|
|
type adminUserResponse struct {
|
|
ID string `json:"id"`
|
|
Username string `json:"username"`
|
|
Email string `json:"email"`
|
|
DisplayName *string `json:"display_name"`
|
|
Role string `json:"role"`
|
|
IsActive bool `json:"is_active"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
LastLoginAt *string `json:"last_login_at"`
|
|
}
|
|
|
|
type adminCreateUserRequest struct {
|
|
Username string `json:"username" binding:"required,min=3,max=50"`
|
|
Email string `json:"email" binding:"required,email"`
|
|
Password string `json:"password" binding:"required,min=8,max=128"`
|
|
Role string `json:"role" binding:"required,oneof=user admin moderator"`
|
|
}
|
|
|
|
type adminResetPasswordRequest struct {
|
|
NewPassword string `json:"new_password" binding:"required,min=8,max=128"`
|
|
}
|
|
|
|
type updateUserRoleRequest struct {
|
|
Role string `json:"role" binding:"required,oneof=user admin moderator"`
|
|
}
|
|
|
|
type updateUserActiveRequest struct {
|
|
IsActive bool `json:"is_active"`
|
|
}
|
|
|
|
type globalSettingResponse struct {
|
|
Key string `json:"key"`
|
|
Value map[string]interface{} `json:"value"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
// AdminHandler manages admin-only operations.
|
|
type AdminHandler struct{}
|
|
|
|
// NewAdminHandler creates a new handler.
|
|
func NewAdminHandler() *AdminHandler {
|
|
return &AdminHandler{}
|
|
}
|
|
|
|
// ── List Users ──────────────────────────────
|
|
|
|
func (h *AdminHandler) ListUsers(c *gin.Context) {
|
|
page, perPage, offset := parsePagination(c)
|
|
|
|
var total int
|
|
if err := database.DB.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&total); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count users"})
|
|
return
|
|
}
|
|
|
|
rows, err := database.DB.Query(`
|
|
SELECT id, username, email, display_name, role, is_active,
|
|
created_at, updated_at, last_login_at
|
|
FROM users
|
|
ORDER BY created_at ASC
|
|
LIMIT $1 OFFSET $2
|
|
`, perPage, offset)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list users"})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
users := make([]adminUserResponse, 0)
|
|
for rows.Next() {
|
|
var u adminUserResponse
|
|
if err := rows.Scan(
|
|
&u.ID, &u.Username, &u.Email, &u.DisplayName, &u.Role,
|
|
&u.IsActive, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt,
|
|
); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan user"})
|
|
return
|
|
}
|
|
users = append(users, u)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, paginatedResponse{
|
|
Data: users,
|
|
Page: page,
|
|
PerPage: perPage,
|
|
Total: total,
|
|
TotalPages: int(math.Ceil(float64(total) / float64(perPage))),
|
|
})
|
|
}
|
|
|
|
// ── Create User (admin) ─────────────────────
|
|
|
|
func (h *AdminHandler) CreateUser(c *gin.Context) {
|
|
var req adminCreateUserRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
|
req.Username = strings.TrimSpace(req.Username)
|
|
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
|
|
return
|
|
}
|
|
|
|
var user adminUserResponse
|
|
err = database.DB.QueryRow(`
|
|
INSERT INTO users (username, email, password_hash, role)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING id, username, email, display_name, role, is_active, created_at, updated_at, last_login_at
|
|
`, req.Username, req.Email, string(hash), req.Role).Scan(
|
|
&user.ID, &user.Username, &user.Email, &user.DisplayName,
|
|
&user.Role, &user.IsActive, &user.CreatedAt, &user.UpdatedAt, &user.LastLoginAt,
|
|
)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "duplicate key") {
|
|
field := "email"
|
|
if strings.Contains(err.Error(), "username") {
|
|
field = "username"
|
|
}
|
|
c.JSON(http.StatusConflict, gin.H{"error": field + " already taken"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusCreated, user)
|
|
}
|
|
|
|
// ── Reset Password (admin) ──────────────────
|
|
|
|
func (h *AdminHandler) ResetPassword(c *gin.Context) {
|
|
targetID := c.Param("id")
|
|
|
|
var req adminResetPasswordRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcryptCost)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
|
|
return
|
|
}
|
|
|
|
result, err := database.DB.Exec(
|
|
`UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2`,
|
|
string(hash), targetID,
|
|
)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reset password"})
|
|
return
|
|
}
|
|
rows, _ := result.RowsAffected()
|
|
if rows == 0 {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "password reset"})
|
|
}
|
|
|
|
// ── Update User Role ────────────────────────
|
|
|
|
func (h *AdminHandler) UpdateUserRole(c *gin.Context) {
|
|
targetID := c.Param("id")
|
|
adminID := getUserID(c)
|
|
|
|
var req updateUserRoleRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Prevent admin from demoting themselves
|
|
if targetID == adminID && req.Role != "admin" {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "cannot change your own role"})
|
|
return
|
|
}
|
|
|
|
result, err := database.DB.Exec(
|
|
`UPDATE users SET role = $1, updated_at = NOW() WHERE id = $2`,
|
|
req.Role, targetID,
|
|
)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update role"})
|
|
return
|
|
}
|
|
rows, _ := result.RowsAffected()
|
|
if rows == 0 {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "role updated", "role": req.Role})
|
|
}
|
|
|
|
// ── Toggle User Active ──────────────────────
|
|
|
|
func (h *AdminHandler) ToggleUserActive(c *gin.Context) {
|
|
targetID := c.Param("id")
|
|
adminID := getUserID(c)
|
|
|
|
var req updateUserActiveRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Prevent admin from deactivating themselves
|
|
if targetID == adminID && !req.IsActive {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "cannot deactivate your own account"})
|
|
return
|
|
}
|
|
|
|
result, err := database.DB.Exec(
|
|
`UPDATE users SET is_active = $1, updated_at = NOW() WHERE id = $2`,
|
|
req.IsActive, targetID,
|
|
)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update user"})
|
|
return
|
|
}
|
|
rows, _ := result.RowsAffected()
|
|
if rows == 0 {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "user updated", "is_active": req.IsActive})
|
|
}
|
|
|
|
// ── Delete User ─────────────────────────────
|
|
|
|
func (h *AdminHandler) DeleteUser(c *gin.Context) {
|
|
targetID := c.Param("id")
|
|
adminID := getUserID(c)
|
|
|
|
if targetID == adminID {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "cannot delete your own account"})
|
|
return
|
|
}
|
|
|
|
result, err := database.DB.Exec(`DELETE FROM users WHERE id = $1`, targetID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete user"})
|
|
return
|
|
}
|
|
rows, _ := result.RowsAffected()
|
|
if rows == 0 {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "user deleted"})
|
|
}
|
|
|
|
// ── List Global Settings ────────────────────
|
|
|
|
func (h *AdminHandler) ListGlobalSettings(c *gin.Context) {
|
|
rows, err := database.DB.Query(`
|
|
SELECT key, value::text, updated_at FROM global_settings ORDER BY key
|
|
`)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list settings"})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
settings := make([]globalSettingResponse, 0)
|
|
for rows.Next() {
|
|
var s globalSettingResponse
|
|
var valueRaw string
|
|
if err := rows.Scan(&s.Key, &valueRaw, &s.UpdatedAt); err != nil {
|
|
continue
|
|
}
|
|
s.Value = make(map[string]interface{})
|
|
_ = json.Unmarshal([]byte(valueRaw), &s.Value)
|
|
settings = append(settings, s)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"settings": settings})
|
|
}
|
|
|
|
// ── Get Global Setting ──────────────────────
|
|
|
|
func (h *AdminHandler) GetGlobalSetting(c *gin.Context) {
|
|
key := c.Param("key")
|
|
|
|
var valueRaw, updatedAt string
|
|
err := database.DB.QueryRow(`
|
|
SELECT value::text, updated_at FROM global_settings WHERE key = $1
|
|
`, key).Scan(&valueRaw, &updatedAt)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "setting not found"})
|
|
return
|
|
}
|
|
|
|
value := make(map[string]interface{})
|
|
_ = json.Unmarshal([]byte(valueRaw), &value)
|
|
|
|
c.JSON(http.StatusOK, globalSettingResponse{
|
|
Key: key,
|
|
Value: value,
|
|
UpdatedAt: updatedAt,
|
|
})
|
|
}
|
|
|
|
// ── Update Global Setting ───────────────────
|
|
|
|
func (h *AdminHandler) UpdateGlobalSetting(c *gin.Context) {
|
|
key := c.Param("key")
|
|
adminID := getUserID(c)
|
|
|
|
var value map[string]interface{}
|
|
if err := c.ShouldBindJSON(&value); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
valueJSON, err := json.Marshal(value)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid value"})
|
|
return
|
|
}
|
|
|
|
_, err = database.DB.Exec(`
|
|
INSERT INTO global_settings (key, value, updated_at, updated_by)
|
|
VALUES ($1, $2::jsonb, NOW(), $3)
|
|
ON CONFLICT (key) DO UPDATE SET
|
|
value = $2::jsonb, updated_at = NOW(), updated_by = $3
|
|
`, key, string(valueJSON), adminID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update setting"})
|
|
return
|
|
}
|
|
|
|
h.GetGlobalSetting(c)
|
|
}
|
|
|
|
// ── Admin Stats ─────────────────────────────
|
|
|
|
func (h *AdminHandler) GetStats(c *gin.Context) {
|
|
stats := make(map[string]int)
|
|
|
|
queries := map[string]string{
|
|
"total_users": "SELECT COUNT(*) FROM users",
|
|
"active_users": "SELECT COUNT(*) FROM users WHERE is_active = true",
|
|
"total_chats": "SELECT COUNT(*) FROM chats",
|
|
"total_messages": "SELECT COUNT(*) FROM chat_messages",
|
|
"api_configs": "SELECT COUNT(*) FROM api_configs",
|
|
}
|
|
|
|
for key, query := range queries {
|
|
var count int
|
|
if err := database.DB.QueryRow(query).Scan(&count); err != nil {
|
|
stats[key] = 0
|
|
} else {
|
|
stats[key] = count
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, stats)
|
|
}
|