Enhance member listing functionality with advanced filtering and sorting
- Introduced a new MemberListRequest structure to support advanced filtering options such as status, member_type, role_id, email, and display_name. - Updated the GinMemberList handler to parse and validate query parameters, improving user experience with clearer error messages for invalid inputs. - Enhanced the memberList function to handle advanced filtering and sorting, allowing for more flexible and efficient member retrieval. - Added comprehensive test cases to validate new filtering and sorting functionalities, ensuring robust coverage and reliability in member listing scenarios. - Refactored existing member response structures to include new fields and improve data representation.
This commit is contained in:
parent
420a17f844
commit
772c00e095
4 changed files with 690 additions and 93 deletions
|
|
@ -40,6 +40,29 @@ func TestMemberList(t *testing.T) {
|
|||
createdTeam := createTestTeam(t, serverURL, baseURL, tokenInfo.AccessToken, "Member List Test Team")
|
||||
teamID := getTeamID(createdTeam)
|
||||
|
||||
// Create some test members and robots for filtering tests
|
||||
testUUID := strings.ReplaceAll(uuid.New().String(), "-", "")[:8]
|
||||
|
||||
// Create a robot member for member_type filtering
|
||||
robotBody := map[string]interface{}{
|
||||
"name": "Test Robot " + testUUID,
|
||||
"email": fmt.Sprintf("test-robot-%s@test.com", testUUID),
|
||||
"role": "member",
|
||||
"prompt": "You are a test robot for filtering",
|
||||
}
|
||||
robotBodyBytes, _ := json.Marshal(robotBody)
|
||||
robotReq, _ := http.NewRequest("POST", serverURL+baseURL+"/user/teams/"+teamID+"/members/robots", bytes.NewBuffer(robotBodyBytes))
|
||||
robotReq.Header.Set("Content-Type", "application/json")
|
||||
robotReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
||||
client := &http.Client{}
|
||||
robotResp, err := client.Do(robotReq)
|
||||
if err == nil && robotResp != nil {
|
||||
robotResp.Body.Close()
|
||||
if robotResp.StatusCode != 201 {
|
||||
t.Logf("Warning: Failed to create robot member for testing (status=%d)", robotResp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
teamID string
|
||||
|
|
@ -47,6 +70,7 @@ func TestMemberList(t *testing.T) {
|
|||
headers map[string]string
|
||||
expectCode int
|
||||
expectMsg string
|
||||
validateFn func(*testing.T, map[string]interface{}) // Optional validation function
|
||||
}{
|
||||
{
|
||||
"list members without authentication",
|
||||
|
|
@ -55,6 +79,7 @@ func TestMemberList(t *testing.T) {
|
|||
map[string]string{},
|
||||
401,
|
||||
"should require authentication",
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"list members with valid token",
|
||||
|
|
@ -64,7 +89,47 @@ func TestMemberList(t *testing.T) {
|
|||
"Authorization": "Bearer " + tokenInfo.AccessToken,
|
||||
},
|
||||
200,
|
||||
"should return team members",
|
||||
"should return team members with default sorting (is_owner desc, status desc, created_at desc)",
|
||||
func(t *testing.T, response map[string]interface{}) {
|
||||
// Verify default sorting: is_owner desc first, then status desc
|
||||
if data, ok := response["data"].([]interface{}); ok && len(data) > 1 {
|
||||
foundNonOwner := false
|
||||
foundActive := false
|
||||
|
||||
for _, item := range data {
|
||||
member := item.(map[string]interface{})
|
||||
|
||||
// Check is_owner sorting (owners first)
|
||||
isOwner := false
|
||||
if ownerVal, ok := member["is_owner"]; ok {
|
||||
switch v := ownerVal.(type) {
|
||||
case float64:
|
||||
isOwner = v == 1
|
||||
case int:
|
||||
isOwner = v == 1
|
||||
case bool:
|
||||
isOwner = v
|
||||
}
|
||||
}
|
||||
|
||||
if isOwner {
|
||||
assert.False(t, foundNonOwner, "Owners should come before non-owners")
|
||||
} else {
|
||||
foundNonOwner = true
|
||||
}
|
||||
|
||||
// Check status sorting (pending before active) among non-owners
|
||||
if !isOwner {
|
||||
status := member["status"].(string)
|
||||
if status == "pending" {
|
||||
assert.False(t, foundActive, "Pending members should come before active members")
|
||||
} else if status == "active" {
|
||||
foundActive = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"list members with pagination",
|
||||
|
|
@ -75,6 +140,10 @@ func TestMemberList(t *testing.T) {
|
|||
},
|
||||
200,
|
||||
"should handle pagination parameters",
|
||||
func(t *testing.T, response map[string]interface{}) {
|
||||
assert.Equal(t, float64(1), response["page"], "Should have correct page number")
|
||||
assert.Equal(t, float64(10), response["pagesize"], "Should have correct pagesize")
|
||||
},
|
||||
},
|
||||
{
|
||||
"list members with status filter",
|
||||
|
|
@ -85,6 +154,213 @@ func TestMemberList(t *testing.T) {
|
|||
},
|
||||
200,
|
||||
"should filter by status",
|
||||
func(t *testing.T, response map[string]interface{}) {
|
||||
if data, ok := response["data"].([]interface{}); ok {
|
||||
for _, item := range data {
|
||||
member := item.(map[string]interface{})
|
||||
assert.Equal(t, "active", member["status"], "All members should have active status")
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"list members filtered by member_type user",
|
||||
teamID,
|
||||
"?member_type=user",
|
||||
map[string]string{
|
||||
"Authorization": "Bearer " + tokenInfo.AccessToken,
|
||||
},
|
||||
200,
|
||||
"should filter by member_type=user",
|
||||
func(t *testing.T, response map[string]interface{}) {
|
||||
if data, ok := response["data"].([]interface{}); ok {
|
||||
for _, item := range data {
|
||||
member := item.(map[string]interface{})
|
||||
assert.Equal(t, "user", member["member_type"], "All members should be user type")
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"list members filtered by member_type robot",
|
||||
teamID,
|
||||
"?member_type=robot",
|
||||
map[string]string{
|
||||
"Authorization": "Bearer " + tokenInfo.AccessToken,
|
||||
},
|
||||
200,
|
||||
"should filter by member_type=robot",
|
||||
func(t *testing.T, response map[string]interface{}) {
|
||||
if data, ok := response["data"].([]interface{}); ok {
|
||||
for _, item := range data {
|
||||
member := item.(map[string]interface{})
|
||||
assert.Equal(t, "robot", member["member_type"], "All members should be robot type")
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"list members filtered by role_id",
|
||||
teamID,
|
||||
"?role_id=owner:free",
|
||||
map[string]string{
|
||||
"Authorization": "Bearer " + tokenInfo.AccessToken,
|
||||
},
|
||||
200,
|
||||
"should filter by role_id",
|
||||
func(t *testing.T, response map[string]interface{}) {
|
||||
if data, ok := response["data"].([]interface{}); ok {
|
||||
for _, item := range data {
|
||||
member := item.(map[string]interface{})
|
||||
assert.Equal(t, "owner:free", member["role_id"], "All members should have owner:free role")
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"list members with order by created_at asc",
|
||||
teamID,
|
||||
"?order=created_at+asc",
|
||||
map[string]string{
|
||||
"Authorization": "Bearer " + tokenInfo.AccessToken,
|
||||
},
|
||||
200,
|
||||
"should sort by is_owner desc, status desc, then created_at ascending",
|
||||
func(t *testing.T, response map[string]interface{}) {
|
||||
// Verify owner and status sorting priority
|
||||
if data, ok := response["data"].([]interface{}); ok && len(data) > 1 {
|
||||
foundNonOwner := false
|
||||
for _, item := range data {
|
||||
member := item.(map[string]interface{})
|
||||
|
||||
isOwner := false
|
||||
if ownerVal, ok := member["is_owner"]; ok {
|
||||
switch v := ownerVal.(type) {
|
||||
case float64:
|
||||
isOwner = v == 1
|
||||
case int:
|
||||
isOwner = v == 1
|
||||
case bool:
|
||||
isOwner = v
|
||||
}
|
||||
}
|
||||
|
||||
if isOwner {
|
||||
assert.False(t, foundNonOwner, "Owners should come before non-owners even with custom sorting")
|
||||
} else {
|
||||
foundNonOwner = true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"list members with order by joined_at desc",
|
||||
teamID,
|
||||
"?order=joined_at+desc",
|
||||
map[string]string{
|
||||
"Authorization": "Bearer " + tokenInfo.AccessToken,
|
||||
},
|
||||
200,
|
||||
"should sort by is_owner desc, status desc, then joined_at descending",
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"list members with order by joined_at (default desc)",
|
||||
teamID,
|
||||
"?order=joined_at",
|
||||
map[string]string{
|
||||
"Authorization": "Bearer " + tokenInfo.AccessToken,
|
||||
},
|
||||
200,
|
||||
"should sort by is_owner desc, status desc, then joined_at with default desc direction",
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"list members with field selection",
|
||||
teamID,
|
||||
"?fields=id,user_id,member_type,role_id,status",
|
||||
map[string]string{
|
||||
"Authorization": "Bearer " + tokenInfo.AccessToken,
|
||||
},
|
||||
200,
|
||||
"should return only selected fields",
|
||||
func(t *testing.T, response map[string]interface{}) {
|
||||
if data, ok := response["data"].([]interface{}); ok && len(data) > 0 {
|
||||
member := data[0].(map[string]interface{})
|
||||
// Should have selected fields
|
||||
assert.Contains(t, member, "id", "Should have id field")
|
||||
assert.Contains(t, member, "user_id", "Should have user_id field")
|
||||
assert.Contains(t, member, "member_type", "Should have member_type field")
|
||||
assert.Contains(t, member, "role_id", "Should have role_id field")
|
||||
assert.Contains(t, member, "status", "Should have status field")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"list members with invalid status value",
|
||||
teamID,
|
||||
"?status=invalid_status",
|
||||
map[string]string{
|
||||
"Authorization": "Bearer " + tokenInfo.AccessToken,
|
||||
},
|
||||
400,
|
||||
"should reject invalid status value",
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"list members with invalid member_type value",
|
||||
teamID,
|
||||
"?member_type=invalid_type",
|
||||
map[string]string{
|
||||
"Authorization": "Bearer " + tokenInfo.AccessToken,
|
||||
},
|
||||
400,
|
||||
"should reject invalid member_type value",
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"list members with invalid order field",
|
||||
teamID,
|
||||
"?order=invalid_field+desc",
|
||||
map[string]string{
|
||||
"Authorization": "Bearer " + tokenInfo.AccessToken,
|
||||
},
|
||||
400,
|
||||
"should reject invalid order field",
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"list members with invalid order direction",
|
||||
teamID,
|
||||
"?order=created_at+invalid",
|
||||
map[string]string{
|
||||
"Authorization": "Bearer " + tokenInfo.AccessToken,
|
||||
},
|
||||
400,
|
||||
"should reject invalid order direction",
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"list members with combined filters",
|
||||
teamID,
|
||||
"?status=active&member_type=user&order=created_at+asc&page=1&pagesize=5",
|
||||
map[string]string{
|
||||
"Authorization": "Bearer " + tokenInfo.AccessToken,
|
||||
},
|
||||
200,
|
||||
"should handle combined filters and sorting",
|
||||
func(t *testing.T, response map[string]interface{}) {
|
||||
assert.Equal(t, float64(1), response["page"], "Should have correct page number")
|
||||
assert.Equal(t, float64(5), response["pagesize"], "Should have correct pagesize")
|
||||
if data, ok := response["data"].([]interface{}); ok {
|
||||
for _, item := range data {
|
||||
member := item.(map[string]interface{})
|
||||
assert.Equal(t, "active", member["status"], "All members should have active status")
|
||||
assert.Equal(t, "user", member["member_type"], "All members should be user type")
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"list members of non-existent team",
|
||||
|
|
@ -95,6 +371,7 @@ func TestMemberList(t *testing.T) {
|
|||
},
|
||||
404,
|
||||
"should return not found for non-existent team",
|
||||
nil,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -109,8 +386,8 @@ func TestMemberList(t *testing.T) {
|
|||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
httpClient := &http.Client{}
|
||||
resp, err := httpClient.Do(req)
|
||||
assert.NoError(t, err, "HTTP request should succeed")
|
||||
|
||||
if resp != nil {
|
||||
|
|
@ -132,14 +409,9 @@ func TestMemberList(t *testing.T) {
|
|||
assert.Contains(t, response, "page", "Should have page number")
|
||||
assert.Contains(t, response, "pagesize", "Should have pagesize")
|
||||
|
||||
// Verify that creator is automatically added as member
|
||||
if data, ok := response["data"].([]interface{}); ok {
|
||||
assert.GreaterOrEqual(t, len(data), 1, "Should have at least the owner as member")
|
||||
if len(data) > 0 {
|
||||
member := data[0].(map[string]interface{})
|
||||
assert.Equal(t, tokenInfo.UserID, member["user_id"], "Owner should be in member list")
|
||||
assert.Equal(t, "owner:free", member["role_id"], "Creator should have owner:free role")
|
||||
}
|
||||
// Run custom validation if provided
|
||||
if tc.validateFn != nil {
|
||||
tc.validateFn(t, response)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -746,7 +1018,7 @@ func createTestTeam(t *testing.T, serverURL, baseURL, accessToken, teamName stri
|
|||
// createTestMember creates a member for testing using provider directly (no API call).
|
||||
// This is the recommended approach since direct member creation endpoint was removed.
|
||||
// Members should normally be added via invitation flow or robot creation endpoint.
|
||||
// Returns the user_id which serves as member_id in API context.
|
||||
// Returns the member_id (global unique identifier).
|
||||
func createTestMember(t *testing.T, serverURL, baseURL, teamID, accessToken, userID string) string {
|
||||
// Get user provider for direct database operations
|
||||
provider := testutils.GetUserProvider(t)
|
||||
|
|
@ -768,11 +1040,11 @@ func createTestMember(t *testing.T, serverURL, baseURL, teamID, accessToken, use
|
|||
|
||||
t.Logf("Created test member directly in database: user_id=%s, member_id=%s, team_id=%s", userID, memberID, teamID)
|
||||
|
||||
// Return user_id (which is used as member identifier in API context)
|
||||
return userID
|
||||
// Return member_id (global unique identifier used in API)
|
||||
return memberID
|
||||
}
|
||||
|
||||
// getOwnerMemberID gets the user_id of the team owner (which serves as member_id in API context)
|
||||
// getOwnerMemberID gets the member_id of the team owner (global unique identifier)
|
||||
func getOwnerMemberID(t *testing.T, serverURL, baseURL, teamID, accessToken string) string {
|
||||
req, err := http.NewRequest("GET", serverURL+baseURL+"/user/teams/"+teamID+"/members", nil)
|
||||
assert.NoError(t, err, "Should create member list request")
|
||||
|
|
@ -797,15 +1069,15 @@ func getOwnerMemberID(t *testing.T, serverURL, baseURL, teamID, accessToken stri
|
|||
assert.True(t, ok, "Should have data array")
|
||||
assert.Greater(t, len(data), 0, "Should have at least one member")
|
||||
|
||||
// Find the owner member and return their user_id
|
||||
// Find the owner member and return their member_id
|
||||
for _, item := range data {
|
||||
member := item.(map[string]interface{})
|
||||
if role, ok := member["role_id"].(string); ok && strings.HasPrefix(role, "owner") {
|
||||
userID, ok := member["user_id"].(string)
|
||||
memberID, ok := member["member_id"].(string)
|
||||
if !ok {
|
||||
t.Fatal("Owner member missing user_id")
|
||||
t.Fatal("Owner member missing member_id")
|
||||
}
|
||||
return userID
|
||||
return memberID
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -21,9 +20,8 @@ import (
|
|||
|
||||
// Member Management Handlers
|
||||
|
||||
// GinMemberList handles GET /teams/:team_id/members - Get team members
|
||||
// GinMemberList handles GET /teams/:team_id/members - Get team members with advanced filtering
|
||||
func GinMemberList(c *gin.Context) {
|
||||
|
||||
authInfo := authorized.GetInfo(c)
|
||||
if authInfo == nil || authInfo.UserID == "" {
|
||||
errorResp := &response.ErrorResponse{
|
||||
|
|
@ -44,24 +42,47 @@ func GinMemberList(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// Parse pagination parameters
|
||||
page := 1
|
||||
pagesize := 20
|
||||
|
||||
if p := c.Query("page"); p != "" {
|
||||
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
|
||||
page = parsed
|
||||
// Parse request parameters
|
||||
var req MemberListRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
// Provide a more user-friendly error message
|
||||
errMsg := "Invalid query parameters"
|
||||
if strings.Contains(err.Error(), "parsing") {
|
||||
errMsg = "Invalid query parameter format. Please check page, pagesize, and other numeric values."
|
||||
}
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: errMsg,
|
||||
}
|
||||
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
if ps := c.Query("pagesize"); ps != "" {
|
||||
if parsed, err := strconv.Atoi(ps); err == nil && parsed > 0 && parsed <= 100 {
|
||||
pagesize = parsed
|
||||
// Set default values
|
||||
if req.Page <= 0 {
|
||||
req.Page = 1
|
||||
}
|
||||
if req.PageSize <= 0 {
|
||||
req.PageSize = 20
|
||||
}
|
||||
if req.PageSize > 100 {
|
||||
req.PageSize = 100
|
||||
}
|
||||
if req.Order == "" {
|
||||
req.Order = "created_at desc"
|
||||
}
|
||||
|
||||
// Parse fields from comma-separated string if provided
|
||||
if fieldsStr := c.Query("fields"); fieldsStr != "" {
|
||||
req.Fields = strings.Split(fieldsStr, ",")
|
||||
// Trim spaces from field names
|
||||
for i, field := range req.Fields {
|
||||
req.Fields[i] = strings.TrimSpace(field)
|
||||
}
|
||||
}
|
||||
|
||||
// Call business logic
|
||||
result, err := memberList(c.Request.Context(), authInfo.UserID, teamID, page, pagesize, c.Query("status"))
|
||||
result, err := memberList(c.Request.Context(), authInfo.UserID, teamID, &req)
|
||||
if err != nil {
|
||||
log.Error("Failed to get team members: %v", err)
|
||||
// Check error type for appropriate response
|
||||
|
|
@ -77,6 +98,12 @@ func GinMemberList(c *gin.Context) {
|
|||
ErrorDescription: err.Error(),
|
||||
}
|
||||
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
||||
} else if strings.Contains(err.Error(), "invalid") {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: err.Error(),
|
||||
}
|
||||
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
||||
} else {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrServerError.Code,
|
||||
|
|
@ -456,7 +483,16 @@ func GinMemberDelete(c *gin.Context) {
|
|||
|
||||
// ProcessMemberList user.member.list Member list processor
|
||||
// Args[0] string: team_id
|
||||
// Args[1] map: Query parameters {"status": "active", "page": 1, "pagesize": 20}
|
||||
// Args[1] map: Query parameters with advanced filtering
|
||||
//
|
||||
// {
|
||||
// "page": 1, "pagesize": 20,
|
||||
// "status": "active", "member_type": "user", "role_id": "admin",
|
||||
// "email": "test@example.com", "display_name": "John",
|
||||
// "order": "created_at desc",
|
||||
// "fields": ["id", "user_id", "display_name", "role_id"]
|
||||
// }
|
||||
//
|
||||
// Return: map: Paginated member list
|
||||
func ProcessMemberList(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(2)
|
||||
|
|
@ -472,26 +508,64 @@ func ProcessMemberList(process *process.Process) interface{} {
|
|||
// Parse query parameters
|
||||
queryMap := process.ArgsMap(1)
|
||||
|
||||
// Parse pagination
|
||||
page := 1
|
||||
pagesize := 20
|
||||
// Build request object
|
||||
req := &MemberListRequest{
|
||||
Page: 1,
|
||||
PageSize: 20,
|
||||
Order: "created_at desc",
|
||||
}
|
||||
|
||||
// Parse pagination
|
||||
if p, ok := queryMap["page"]; ok {
|
||||
if pageInt, ok := p.(int); ok && pageInt > 0 {
|
||||
page = pageInt
|
||||
req.Page = pageInt
|
||||
}
|
||||
}
|
||||
|
||||
if ps, ok := queryMap["pagesize"]; ok {
|
||||
if pagesizeInt, ok := ps.(int); ok && pagesizeInt > 0 && pagesizeInt <= 100 {
|
||||
pagesize = pagesizeInt
|
||||
req.PageSize = pagesizeInt
|
||||
}
|
||||
}
|
||||
|
||||
// Get status filter
|
||||
status := ""
|
||||
if s, ok := queryMap["status"].(string); ok {
|
||||
status = s
|
||||
// Parse filters
|
||||
if status, ok := queryMap["status"].(string); ok {
|
||||
req.Status = status
|
||||
}
|
||||
|
||||
if memberType, ok := queryMap["member_type"].(string); ok {
|
||||
req.MemberType = memberType
|
||||
}
|
||||
|
||||
if roleID, ok := queryMap["role_id"].(string); ok {
|
||||
req.RoleID = roleID
|
||||
}
|
||||
|
||||
if email, ok := queryMap["email"].(string); ok {
|
||||
req.Email = email
|
||||
}
|
||||
|
||||
if displayName, ok := queryMap["display_name"].(string); ok {
|
||||
req.DisplayName = displayName
|
||||
}
|
||||
|
||||
// Parse sorting
|
||||
if order, ok := queryMap["order"].(string); ok {
|
||||
req.Order = order
|
||||
}
|
||||
|
||||
// Parse fields selection
|
||||
if fields, ok := queryMap["fields"]; ok {
|
||||
if fieldsSlice, ok := fields.([]interface{}); ok {
|
||||
req.Fields = make([]string, 0, len(fieldsSlice))
|
||||
for _, f := range fieldsSlice {
|
||||
if fieldStr, ok := f.(string); ok {
|
||||
req.Fields = append(req.Fields, fieldStr)
|
||||
}
|
||||
}
|
||||
} else if fieldsStrSlice, ok := fields.([]string); ok {
|
||||
req.Fields = fieldsStrSlice
|
||||
}
|
||||
}
|
||||
|
||||
// Get context
|
||||
|
|
@ -501,7 +575,7 @@ func ProcessMemberList(process *process.Process) interface{} {
|
|||
}
|
||||
|
||||
// Call business logic
|
||||
result, err := memberList(ctx, userIDStr, teamID, page, pagesize, status)
|
||||
result, err := memberList(ctx, userIDStr, teamID, req)
|
||||
if err != nil {
|
||||
exception.New("failed to list members: %s", 500, err.Error()).Throw()
|
||||
}
|
||||
|
|
@ -613,8 +687,8 @@ func ProcessMemberDelete(process *process.Process) interface{} {
|
|||
|
||||
// Private Business Logic Functions (internal use only)
|
||||
|
||||
// memberList handles the business logic for listing team members
|
||||
func memberList(ctx context.Context, userID, teamID string, page, pagesize int, status string) (maps.MapStr, error) {
|
||||
// memberList handles the business logic for listing team members with advanced filtering
|
||||
func memberList(ctx context.Context, userID, teamID string, req *MemberListRequest) (maps.MapStr, error) {
|
||||
// Check if user has access to the team (read permission: owner or member)
|
||||
isOwner, isMember, err := checkTeamAccess(ctx, teamID, userID)
|
||||
if err != nil {
|
||||
|
|
@ -637,22 +711,117 @@ func memberList(ctx context.Context, userID, teamID string, page, pagesize int,
|
|||
Wheres: []model.QueryWhere{
|
||||
{Column: "team_id", Value: teamID},
|
||||
},
|
||||
Orders: []model.QueryOrder{
|
||||
{Column: "joined_at", Option: "desc"},
|
||||
{Column: "created_at", Option: "desc"},
|
||||
},
|
||||
}
|
||||
|
||||
// Add status filter if provided
|
||||
if status != "" {
|
||||
// Add filters
|
||||
if req.Status != "" {
|
||||
// Validate status values
|
||||
validStatuses := map[string]bool{
|
||||
"pending": true, "active": true, "inactive": true, "suspended": true,
|
||||
}
|
||||
if !validStatuses[req.Status] {
|
||||
return nil, fmt.Errorf("invalid status value: %s (must be one of: pending, active, inactive, suspended)", req.Status)
|
||||
}
|
||||
param.Wheres = append(param.Wheres, model.QueryWhere{
|
||||
Column: "status",
|
||||
Value: status,
|
||||
Value: req.Status,
|
||||
})
|
||||
}
|
||||
|
||||
if req.MemberType != "" {
|
||||
// Validate member type values
|
||||
validTypes := map[string]bool{
|
||||
"user": true, "robot": true,
|
||||
}
|
||||
if !validTypes[req.MemberType] {
|
||||
return nil, fmt.Errorf("invalid member_type value: %s (must be one of: user, robot)", req.MemberType)
|
||||
}
|
||||
param.Wheres = append(param.Wheres, model.QueryWhere{
|
||||
Column: "member_type",
|
||||
Value: req.MemberType,
|
||||
})
|
||||
}
|
||||
|
||||
if req.RoleID != "" {
|
||||
param.Wheres = append(param.Wheres, model.QueryWhere{
|
||||
Column: "role_id",
|
||||
Value: req.RoleID,
|
||||
})
|
||||
}
|
||||
|
||||
if req.Email != "" {
|
||||
param.Wheres = append(param.Wheres, model.QueryWhere{
|
||||
Column: "email",
|
||||
Value: req.Email,
|
||||
})
|
||||
}
|
||||
|
||||
if req.DisplayName != "" {
|
||||
param.Wheres = append(param.Wheres, model.QueryWhere{
|
||||
Column: "display_name",
|
||||
Value: req.DisplayName,
|
||||
OP: "like",
|
||||
})
|
||||
}
|
||||
|
||||
// Parse and validate sorting
|
||||
validOrderFields := map[string]bool{
|
||||
"created_at": true,
|
||||
"joined_at": true,
|
||||
}
|
||||
validOrderDirs := map[string]bool{
|
||||
"asc": true, "desc": true,
|
||||
}
|
||||
|
||||
// Parse order field (format: "field_name [asc|desc]")
|
||||
orderParts := strings.Fields(req.Order) // Split by whitespace
|
||||
orderBy := ""
|
||||
orderDir := "desc" // Default direction
|
||||
|
||||
if len(orderParts) > 0 {
|
||||
orderBy = orderParts[0]
|
||||
if len(orderParts) > 1 {
|
||||
orderDir = strings.ToLower(orderParts[1])
|
||||
}
|
||||
}
|
||||
|
||||
// Build sorting with priority: owner first, then pending invitations, then others
|
||||
orders := []model.QueryOrder{
|
||||
{Column: "is_owner", Option: "desc"}, // Owners always first
|
||||
{Column: "status", Option: "asc"}, // Then pending before active (enum index: pending=1 < active=2 < inactive=3 < suspended=4)
|
||||
}
|
||||
|
||||
// Validate and add user-specified order field
|
||||
if orderBy != "" {
|
||||
if !validOrderFields[orderBy] {
|
||||
return nil, fmt.Errorf("invalid order field: %s (must be one of: created_at, joined_at)", orderBy)
|
||||
}
|
||||
if !validOrderDirs[orderDir] {
|
||||
return nil, fmt.Errorf("invalid order direction: %s (must be one of: asc, desc)", orderDir)
|
||||
}
|
||||
orders = append(orders, model.QueryOrder{
|
||||
Column: orderBy, Option: orderDir,
|
||||
})
|
||||
} else {
|
||||
// Default tertiary sorting
|
||||
orders = append(orders, model.QueryOrder{
|
||||
Column: "created_at", Option: "desc",
|
||||
})
|
||||
}
|
||||
|
||||
param.Orders = orders
|
||||
|
||||
// Add field selection if specified
|
||||
if len(req.Fields) > 0 {
|
||||
// Convert []string to []interface{} for QueryParam.Select
|
||||
param.Select = make([]interface{}, len(req.Fields))
|
||||
for i, field := range req.Fields {
|
||||
param.Select[i] = field
|
||||
}
|
||||
}
|
||||
|
||||
// Get paginated members
|
||||
result, err := provider.PaginateMembers(ctx, param, page, pagesize)
|
||||
result, err := provider.PaginateMembers(ctx, param, req.Page, req.PageSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to retrieve members: %w", err)
|
||||
}
|
||||
|
|
@ -679,9 +848,8 @@ func memberGet(ctx context.Context, userID, teamID, memberID string) (maps.MapSt
|
|||
return nil, fmt.Errorf("failed to get user provider: %w", err)
|
||||
}
|
||||
|
||||
// Get member details using team_id + user_id (business keys)
|
||||
// memberID parameter is actually user_id in the context of team_id
|
||||
memberData, err := provider.GetMember(ctx, teamID, memberID)
|
||||
// Get member details using member_id (with all fields including robot config)
|
||||
memberData, err := provider.GetMemberDetailByMemberID(ctx, memberID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("member not found: %w", err)
|
||||
}
|
||||
|
|
@ -746,7 +914,7 @@ func memberCreateRobot(ctx context.Context, userID, teamID string, robotData map
|
|||
}
|
||||
|
||||
// memberUpdate handles the business logic for updating a team member
|
||||
func memberUpdate(ctx context.Context, userID, teamID, memberUserID string, updateData maps.MapStrAny) error {
|
||||
func memberUpdate(ctx context.Context, userID, teamID, memberID string, updateData maps.MapStrAny) error {
|
||||
// Check if user has access to the team (write permission: owner only)
|
||||
isOwner, _, err := checkTeamAccess(ctx, teamID, userID)
|
||||
if err != nil {
|
||||
|
|
@ -764,8 +932,8 @@ func memberUpdate(ctx context.Context, userID, teamID, memberUserID string, upda
|
|||
return fmt.Errorf("failed to get user provider: %w", err)
|
||||
}
|
||||
|
||||
// Check if member exists using team_id + user_id (business keys)
|
||||
_, err = provider.GetMember(ctx, teamID, memberUserID)
|
||||
// Check if member exists using member_id
|
||||
_, err = provider.GetMemberByMemberID(ctx, memberID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("member not found: %w", err)
|
||||
}
|
||||
|
|
@ -773,8 +941,8 @@ func memberUpdate(ctx context.Context, userID, teamID, memberUserID string, upda
|
|||
// Add updated_at timestamp
|
||||
updateData["updated_at"] = time.Now()
|
||||
|
||||
// Update member using team_id + user_id
|
||||
err = provider.UpdateMember(ctx, teamID, memberUserID, updateData)
|
||||
// Update member using member_id
|
||||
err = provider.UpdateMemberByMemberID(ctx, memberID, updateData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update member: %w", err)
|
||||
}
|
||||
|
|
@ -801,15 +969,14 @@ func memberDelete(ctx context.Context, userID, teamID, memberID string) error {
|
|||
return fmt.Errorf("failed to get user provider: %w", err)
|
||||
}
|
||||
|
||||
// Check if member exists using team_id + user_id (business keys)
|
||||
// memberID parameter is actually user_id in the context of team_id
|
||||
_, err = provider.GetMember(ctx, teamID, memberID)
|
||||
// Check if member exists using member_id
|
||||
_, err = provider.GetMemberByMemberID(ctx, memberID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("member not found: %w", err)
|
||||
}
|
||||
|
||||
// Remove member using team_id + user_id
|
||||
err = provider.RemoveMember(ctx, teamID, memberID)
|
||||
// Remove member using member_id
|
||||
err = provider.RemoveMemberByMemberID(ctx, memberID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete member: %w", err)
|
||||
}
|
||||
|
|
@ -835,18 +1002,28 @@ func checkTeamAccess(ctx context.Context, teamID, userID string) (bool, bool, er
|
|||
// mapToMemberResponse converts a map to MemberResponse
|
||||
func mapToMemberResponse(data maps.MapStr) MemberResponse {
|
||||
member := MemberResponse{
|
||||
ID: toInt64(data["id"]),
|
||||
TeamID: toString(data["team_id"]),
|
||||
UserID: toString(data["user_id"]),
|
||||
MemberType: toString(data["member_type"]),
|
||||
RoleID: toString(data["role_id"]),
|
||||
Status: toString(data["status"]),
|
||||
InvitedBy: toString(data["invited_by"]),
|
||||
InvitedAt: toTimeString(data["invited_at"]),
|
||||
JoinedAt: toTimeString(data["joined_at"]),
|
||||
LastActivity: toTimeString(data["last_activity"]),
|
||||
CreatedAt: toTimeString(data["created_at"]),
|
||||
UpdatedAt: toTimeString(data["updated_at"]),
|
||||
ID: toInt64(data["id"]),
|
||||
MemberID: toString(data["member_id"]),
|
||||
TeamID: toString(data["team_id"]),
|
||||
UserID: toString(data["user_id"]),
|
||||
MemberType: toString(data["member_type"]),
|
||||
DisplayName: toString(data["display_name"]),
|
||||
Bio: toString(data["bio"]),
|
||||
Avatar: toString(data["avatar"]),
|
||||
Email: toString(data["email"]),
|
||||
RoleID: toString(data["role_id"]),
|
||||
IsOwner: data["is_owner"], // Keep original type (int or bool)
|
||||
Status: toString(data["status"]),
|
||||
InvitationID: toString(data["invitation_id"]),
|
||||
InvitedBy: toString(data["invited_by"]),
|
||||
InvitedAt: toTimeString(data["invited_at"]),
|
||||
InvitationToken: toString(data["invitation_token"]),
|
||||
InvitationExpiresAt: toTimeString(data["invitation_expires_at"]),
|
||||
JoinedAt: toTimeString(data["joined_at"]),
|
||||
LastActiveAt: toTimeString(data["last_active_at"]),
|
||||
LoginCount: toInt(data["login_count"]),
|
||||
CreatedAt: toTimeString(data["created_at"]),
|
||||
UpdatedAt: toTimeString(data["updated_at"]),
|
||||
}
|
||||
|
||||
// Add settings if available
|
||||
|
|
@ -883,6 +1060,59 @@ func mapToMemberResponse(data maps.MapStr) MemberResponse {
|
|||
func mapToMemberDetailResponse(data maps.MapStr) MemberDetailResponse {
|
||||
member := MemberDetailResponse{
|
||||
MemberResponse: mapToMemberResponse(data),
|
||||
// Robot-specific fields
|
||||
SystemPrompt: toString(data["system_prompt"]),
|
||||
ManagerID: toString(data["manager_id"]),
|
||||
LanguageModel: toString(data["language_model"]),
|
||||
CostLimit: toFloat64(data["cost_limit"]),
|
||||
AutonomousMode: data["autonomous_mode"], // Keep original type (bool or string)
|
||||
LastRobotActivity: toTimeString(data["last_robot_activity"]),
|
||||
RobotStatus: toString(data["robot_status"]),
|
||||
Notes: toString(data["notes"]),
|
||||
}
|
||||
|
||||
// Handle robot_config map
|
||||
if robotConfig, ok := data["robot_config"]; ok {
|
||||
if configMap, ok := robotConfig.(map[string]interface{}); ok {
|
||||
member.RobotConfig = configMap
|
||||
}
|
||||
}
|
||||
|
||||
// Handle agents array
|
||||
if agents, ok := data["agents"]; ok {
|
||||
if agentsSlice, ok := agents.([]interface{}); ok {
|
||||
agentsList := make([]string, 0, len(agentsSlice))
|
||||
for _, a := range agentsSlice {
|
||||
if agentStr, ok := a.(string); ok {
|
||||
agentsList = append(agentsList, agentStr)
|
||||
}
|
||||
}
|
||||
member.Agents = agentsList
|
||||
} else if agentsStrSlice, ok := agents.([]string); ok {
|
||||
member.Agents = agentsStrSlice
|
||||
}
|
||||
}
|
||||
|
||||
// Handle mcp_servers array
|
||||
if mcpServers, ok := data["mcp_servers"]; ok {
|
||||
if serversSlice, ok := mcpServers.([]interface{}); ok {
|
||||
serversList := make([]string, 0, len(serversSlice))
|
||||
for _, s := range serversSlice {
|
||||
if serverStr, ok := s.(string); ok {
|
||||
serversList = append(serversList, serverStr)
|
||||
}
|
||||
}
|
||||
member.MCPServers = serversList
|
||||
} else if serversStrSlice, ok := mcpServers.([]string); ok {
|
||||
member.MCPServers = serversStrSlice
|
||||
}
|
||||
}
|
||||
|
||||
// Handle metadata map
|
||||
if metadata, ok := data["metadata"]; ok {
|
||||
if metadataMap, ok := metadata.(map[string]interface{}); ok {
|
||||
member.Metadata = metadataMap
|
||||
}
|
||||
}
|
||||
|
||||
// Add user info if available (could be joined from user table)
|
||||
|
|
|
|||
|
|
@ -394,25 +394,48 @@ type TeamSelectionRequest struct {
|
|||
|
||||
// MemberResponse represents a team member in API responses
|
||||
type MemberResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
TeamID string `json:"team_id"`
|
||||
UserID string `json:"user_id"`
|
||||
MemberType string `json:"member_type"`
|
||||
RoleID string `json:"role_id"`
|
||||
Status string `json:"status"`
|
||||
InvitedBy string `json:"invited_by,omitempty"`
|
||||
InvitedAt string `json:"invited_at,omitempty"`
|
||||
JoinedAt string `json:"joined_at,omitempty"`
|
||||
LastActivity string `json:"last_activity,omitempty"`
|
||||
Settings *MemberSettings `json:"settings,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ID int64 `json:"id"`
|
||||
MemberID string `json:"member_id,omitempty"`
|
||||
TeamID string `json:"team_id"`
|
||||
UserID string `json:"user_id"`
|
||||
MemberType string `json:"member_type"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
Bio string `json:"bio,omitempty"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
RoleID string `json:"role_id"`
|
||||
IsOwner interface{} `json:"is_owner,omitempty"` // Can be int or bool
|
||||
Status string `json:"status"`
|
||||
InvitationID string `json:"invitation_id,omitempty"`
|
||||
InvitedBy string `json:"invited_by,omitempty"`
|
||||
InvitedAt string `json:"invited_at,omitempty"`
|
||||
InvitationToken string `json:"invitation_token,omitempty"`
|
||||
InvitationExpiresAt string `json:"invitation_expires_at,omitempty"`
|
||||
JoinedAt string `json:"joined_at,omitempty"`
|
||||
LastActiveAt string `json:"last_active_at,omitempty"`
|
||||
LoginCount int `json:"login_count,omitempty"`
|
||||
Settings *MemberSettings `json:"settings,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// MemberDetailResponse represents detailed member information
|
||||
type MemberDetailResponse struct {
|
||||
MemberResponse
|
||||
// Add additional fields that are only included in detailed responses
|
||||
// Robot-specific fields (only for robot members)
|
||||
SystemPrompt string `json:"system_prompt,omitempty"`
|
||||
ManagerID string `json:"manager_id,omitempty"`
|
||||
RobotConfig map[string]interface{} `json:"robot_config,omitempty"`
|
||||
Agents []string `json:"agents,omitempty"`
|
||||
MCPServers []string `json:"mcp_servers,omitempty"`
|
||||
LanguageModel string `json:"language_model,omitempty"`
|
||||
CostLimit float64 `json:"cost_limit,omitempty"`
|
||||
AutonomousMode interface{} `json:"autonomous_mode,omitempty"` // Can be bool or string
|
||||
LastRobotActivity string `json:"last_robot_activity,omitempty"`
|
||||
RobotStatus string `json:"robot_status,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
// Additional user info (joined from user table)
|
||||
UserInfo map[string]interface{} `json:"user_info,omitempty"`
|
||||
}
|
||||
|
||||
|
|
@ -431,6 +454,26 @@ type CreateRobotMemberRequest struct {
|
|||
CostLimit float64 `json:"cost_limit,omitempty"` // Monthly cost limit in USD
|
||||
}
|
||||
|
||||
// MemberListRequest represents the request to list team members with advanced filtering
|
||||
type MemberListRequest struct {
|
||||
// Pagination
|
||||
Page int `json:"page" form:"page"` // Page number (default: 1)
|
||||
PageSize int `json:"pagesize" form:"pagesize"` // Page size (default: 20, max: 100)
|
||||
|
||||
// Filters
|
||||
Status string `json:"status" form:"status"` // Filter by status: pending, active, inactive, suspended
|
||||
MemberType string `json:"member_type" form:"member_type"` // Filter by type: user, robot
|
||||
RoleID string `json:"role_id" form:"role_id"` // Filter by role ID
|
||||
Email string `json:"email" form:"email"` // Filter by email (exact match)
|
||||
DisplayName string `json:"display_name" form:"display_name"` // Filter by display name (like match)
|
||||
|
||||
// Sorting
|
||||
Order string `json:"order" form:"order"` // Sort order: "field_name [asc|desc]" (e.g., "created_at desc", "joined_at asc"). Direction is optional, defaults to desc
|
||||
|
||||
// Field Selection
|
||||
Fields []string `json:"fields" form:"fields"` // Select specific fields to return (comma-separated in query string)
|
||||
}
|
||||
|
||||
// UpdateMemberRequest represents the request to update a member
|
||||
type UpdateMemberRequest struct {
|
||||
RoleID string `json:"role_id,omitempty"`
|
||||
|
|
|
|||
|
|
@ -161,6 +161,58 @@ func toTimeString(v interface{}) string {
|
|||
}
|
||||
}
|
||||
|
||||
// toInt converts various types to int
|
||||
// Supports: int, int64, float64, string
|
||||
// Returns 0 for nil or unsupported types
|
||||
func toInt(v interface{}) int {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
switch val := v.(type) {
|
||||
case int:
|
||||
return val
|
||||
case int64:
|
||||
return int(val)
|
||||
case float64:
|
||||
return int(val)
|
||||
case string:
|
||||
if parsed, err := strconv.Atoi(val); err == nil {
|
||||
return parsed
|
||||
}
|
||||
return 0
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// toFloat64 converts various types to float64
|
||||
// Supports: float64, int, int64, string
|
||||
// Returns 0.0 for nil or unsupported types
|
||||
func toFloat64(v interface{}) float64 {
|
||||
if v == nil {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
return val
|
||||
case float32:
|
||||
return float64(val)
|
||||
case int:
|
||||
return float64(val)
|
||||
case int64:
|
||||
return float64(val)
|
||||
case string:
|
||||
if parsed, err := strconv.ParseFloat(val, 64); err == nil {
|
||||
return parsed
|
||||
}
|
||||
return 0.0
|
||||
default:
|
||||
return 0.0
|
||||
}
|
||||
}
|
||||
|
||||
// Security Utilities
|
||||
|
||||
// maskEmail masks an email address for privacy protection
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue