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) }