Merge pull request #1240 from trheyi/main

Enhance member listing functionality with advanced filtering and sorting
This commit is contained in:
Max 2025-10-26 14:10:55 +08:00 committed by GitHub
commit 43538ee160
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 690 additions and 93 deletions

View file

@ -40,6 +40,29 @@ func TestMemberList(t *testing.T) {
createdTeam := createTestTeam(t, serverURL, baseURL, tokenInfo.AccessToken, "Member List Test Team") createdTeam := createTestTeam(t, serverURL, baseURL, tokenInfo.AccessToken, "Member List Test Team")
teamID := getTeamID(createdTeam) 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 { testCases := []struct {
name string name string
teamID string teamID string
@ -47,6 +70,7 @@ func TestMemberList(t *testing.T) {
headers map[string]string headers map[string]string
expectCode int expectCode int
expectMsg string expectMsg string
validateFn func(*testing.T, map[string]interface{}) // Optional validation function
}{ }{
{ {
"list members without authentication", "list members without authentication",
@ -55,6 +79,7 @@ func TestMemberList(t *testing.T) {
map[string]string{}, map[string]string{},
401, 401,
"should require authentication", "should require authentication",
nil,
}, },
{ {
"list members with valid token", "list members with valid token",
@ -64,7 +89,47 @@ func TestMemberList(t *testing.T) {
"Authorization": "Bearer " + tokenInfo.AccessToken, "Authorization": "Bearer " + tokenInfo.AccessToken,
}, },
200, 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", "list members with pagination",
@ -75,6 +140,10 @@ func TestMemberList(t *testing.T) {
}, },
200, 200,
"should handle pagination parameters", "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", "list members with status filter",
@ -85,6 +154,213 @@ func TestMemberList(t *testing.T) {
}, },
200, 200,
"should filter by status", "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", "list members of non-existent team",
@ -95,6 +371,7 @@ func TestMemberList(t *testing.T) {
}, },
404, 404,
"should return not found for non-existent team", "should return not found for non-existent team",
nil,
}, },
} }
@ -109,8 +386,8 @@ func TestMemberList(t *testing.T) {
req.Header.Set(key, value) req.Header.Set(key, value)
} }
client := &http.Client{} httpClient := &http.Client{}
resp, err := client.Do(req) resp, err := httpClient.Do(req)
assert.NoError(t, err, "HTTP request should succeed") assert.NoError(t, err, "HTTP request should succeed")
if resp != nil { 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, "page", "Should have page number")
assert.Contains(t, response, "pagesize", "Should have pagesize") assert.Contains(t, response, "pagesize", "Should have pagesize")
// Verify that creator is automatically added as member // Run custom validation if provided
if data, ok := response["data"].([]interface{}); ok { if tc.validateFn != nil {
assert.GreaterOrEqual(t, len(data), 1, "Should have at least the owner as member") tc.validateFn(t, response)
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")
}
} }
} }
@ -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). // createTestMember creates a member for testing using provider directly (no API call).
// This is the recommended approach since direct member creation endpoint was removed. // This is the recommended approach since direct member creation endpoint was removed.
// Members should normally be added via invitation flow or robot creation endpoint. // 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 { func createTestMember(t *testing.T, serverURL, baseURL, teamID, accessToken, userID string) string {
// Get user provider for direct database operations // Get user provider for direct database operations
provider := testutils.GetUserProvider(t) 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) 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 member_id (global unique identifier used in API)
return userID 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 { func getOwnerMemberID(t *testing.T, serverURL, baseURL, teamID, accessToken string) string {
req, err := http.NewRequest("GET", serverURL+baseURL+"/user/teams/"+teamID+"/members", nil) req, err := http.NewRequest("GET", serverURL+baseURL+"/user/teams/"+teamID+"/members", nil)
assert.NoError(t, err, "Should create member list request") 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.True(t, ok, "Should have data array")
assert.Greater(t, len(data), 0, "Should have at least one member") 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 { for _, item := range data {
member := item.(map[string]interface{}) member := item.(map[string]interface{})
if role, ok := member["role_id"].(string); ok && strings.HasPrefix(role, "owner") { 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 { if !ok {
t.Fatal("Owner member missing user_id") t.Fatal("Owner member missing member_id")
} }
return userID return memberID
} }
} }

View file

@ -4,7 +4,6 @@ import (
"context" "context"
"fmt" "fmt"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time" "time"
@ -21,9 +20,8 @@ import (
// Member Management Handlers // 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) { func GinMemberList(c *gin.Context) {
authInfo := authorized.GetInfo(c) authInfo := authorized.GetInfo(c)
if authInfo == nil || authInfo.UserID == "" { if authInfo == nil || authInfo.UserID == "" {
errorResp := &response.ErrorResponse{ errorResp := &response.ErrorResponse{
@ -44,24 +42,47 @@ func GinMemberList(c *gin.Context) {
return return
} }
// Parse pagination parameters // Parse request parameters
page := 1 var req MemberListRequest
pagesize := 20 if err := c.ShouldBindQuery(&req); err != nil {
// Provide a more user-friendly error message
if p := c.Query("page"); p != "" { errMsg := "Invalid query parameters"
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 { if strings.Contains(err.Error(), "parsing") {
page = parsed 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 != "" { // Set default values
if parsed, err := strconv.Atoi(ps); err == nil && parsed > 0 && parsed <= 100 { if req.Page <= 0 {
pagesize = parsed 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 // 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 { if err != nil {
log.Error("Failed to get team members: %v", err) log.Error("Failed to get team members: %v", err)
// Check error type for appropriate response // Check error type for appropriate response
@ -77,6 +98,12 @@ func GinMemberList(c *gin.Context) {
ErrorDescription: err.Error(), ErrorDescription: err.Error(),
} }
response.RespondWithError(c, response.StatusForbidden, errorResp) 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 { } else {
errorResp := &response.ErrorResponse{ errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code, Code: response.ErrServerError.Code,
@ -456,7 +483,16 @@ func GinMemberDelete(c *gin.Context) {
// ProcessMemberList user.member.list Member list processor // ProcessMemberList user.member.list Member list processor
// Args[0] string: team_id // 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 // Return: map: Paginated member list
func ProcessMemberList(process *process.Process) interface{} { func ProcessMemberList(process *process.Process) interface{} {
process.ValidateArgNums(2) process.ValidateArgNums(2)
@ -472,26 +508,64 @@ func ProcessMemberList(process *process.Process) interface{} {
// Parse query parameters // Parse query parameters
queryMap := process.ArgsMap(1) queryMap := process.ArgsMap(1)
// Parse pagination // Build request object
page := 1 req := &MemberListRequest{
pagesize := 20 Page: 1,
PageSize: 20,
Order: "created_at desc",
}
// Parse pagination
if p, ok := queryMap["page"]; ok { if p, ok := queryMap["page"]; ok {
if pageInt, ok := p.(int); ok && pageInt > 0 { if pageInt, ok := p.(int); ok && pageInt > 0 {
page = pageInt req.Page = pageInt
} }
} }
if ps, ok := queryMap["pagesize"]; ok { if ps, ok := queryMap["pagesize"]; ok {
if pagesizeInt, ok := ps.(int); ok && pagesizeInt > 0 && pagesizeInt <= 100 { if pagesizeInt, ok := ps.(int); ok && pagesizeInt > 0 && pagesizeInt <= 100 {
pagesize = pagesizeInt req.PageSize = pagesizeInt
} }
} }
// Get status filter // Parse filters
status := "" if status, ok := queryMap["status"].(string); ok {
if s, ok := queryMap["status"].(string); ok { req.Status = status
status = s }
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 // Get context
@ -501,7 +575,7 @@ func ProcessMemberList(process *process.Process) interface{} {
} }
// Call business logic // Call business logic
result, err := memberList(ctx, userIDStr, teamID, page, pagesize, status) result, err := memberList(ctx, userIDStr, teamID, req)
if err != nil { if err != nil {
exception.New("failed to list members: %s", 500, err.Error()).Throw() 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) // Private Business Logic Functions (internal use only)
// memberList handles the business logic for listing team members // memberList handles the business logic for listing team members with advanced filtering
func memberList(ctx context.Context, userID, teamID string, page, pagesize int, status string) (maps.MapStr, error) { 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) // Check if user has access to the team (read permission: owner or member)
isOwner, isMember, err := checkTeamAccess(ctx, teamID, userID) isOwner, isMember, err := checkTeamAccess(ctx, teamID, userID)
if err != nil { if err != nil {
@ -637,22 +711,117 @@ func memberList(ctx context.Context, userID, teamID string, page, pagesize int,
Wheres: []model.QueryWhere{ Wheres: []model.QueryWhere{
{Column: "team_id", Value: teamID}, {Column: "team_id", Value: teamID},
}, },
Orders: []model.QueryOrder{
{Column: "joined_at", Option: "desc"},
{Column: "created_at", Option: "desc"},
},
} }
// Add status filter if provided // Add filters
if status != "" { 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{ param.Wheres = append(param.Wheres, model.QueryWhere{
Column: "status", 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 // Get paginated members
result, err := provider.PaginateMembers(ctx, param, page, pagesize) result, err := provider.PaginateMembers(ctx, param, req.Page, req.PageSize)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to retrieve members: %w", err) 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) return nil, fmt.Errorf("failed to get user provider: %w", err)
} }
// Get member details using team_id + user_id (business keys) // Get member details using member_id (with all fields including robot config)
// memberID parameter is actually user_id in the context of team_id memberData, err := provider.GetMemberDetailByMemberID(ctx, memberID)
memberData, err := provider.GetMember(ctx, teamID, memberID)
if err != nil { if err != nil {
return nil, fmt.Errorf("member not found: %w", err) 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 // 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) // Check if user has access to the team (write permission: owner only)
isOwner, _, err := checkTeamAccess(ctx, teamID, userID) isOwner, _, err := checkTeamAccess(ctx, teamID, userID)
if err != nil { 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) return fmt.Errorf("failed to get user provider: %w", err)
} }
// Check if member exists using team_id + user_id (business keys) // Check if member exists using member_id
_, err = provider.GetMember(ctx, teamID, memberUserID) _, err = provider.GetMemberByMemberID(ctx, memberID)
if err != nil { if err != nil {
return fmt.Errorf("member not found: %w", err) 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 // Add updated_at timestamp
updateData["updated_at"] = time.Now() updateData["updated_at"] = time.Now()
// Update member using team_id + user_id // Update member using member_id
err = provider.UpdateMember(ctx, teamID, memberUserID, updateData) err = provider.UpdateMemberByMemberID(ctx, memberID, updateData)
if err != nil { if err != nil {
return fmt.Errorf("failed to update member: %w", err) 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) return fmt.Errorf("failed to get user provider: %w", err)
} }
// Check if member exists using team_id + user_id (business keys) // Check if member exists using member_id
// memberID parameter is actually user_id in the context of team_id _, err = provider.GetMemberByMemberID(ctx, memberID)
_, err = provider.GetMember(ctx, teamID, memberID)
if err != nil { if err != nil {
return fmt.Errorf("member not found: %w", err) return fmt.Errorf("member not found: %w", err)
} }
// Remove member using team_id + user_id // Remove member using member_id
err = provider.RemoveMember(ctx, teamID, memberID) err = provider.RemoveMemberByMemberID(ctx, memberID)
if err != nil { if err != nil {
return fmt.Errorf("failed to delete member: %w", err) 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 // mapToMemberResponse converts a map to MemberResponse
func mapToMemberResponse(data maps.MapStr) MemberResponse { func mapToMemberResponse(data maps.MapStr) MemberResponse {
member := MemberResponse{ member := MemberResponse{
ID: toInt64(data["id"]), ID: toInt64(data["id"]),
TeamID: toString(data["team_id"]), MemberID: toString(data["member_id"]),
UserID: toString(data["user_id"]), TeamID: toString(data["team_id"]),
MemberType: toString(data["member_type"]), UserID: toString(data["user_id"]),
RoleID: toString(data["role_id"]), MemberType: toString(data["member_type"]),
Status: toString(data["status"]), DisplayName: toString(data["display_name"]),
InvitedBy: toString(data["invited_by"]), Bio: toString(data["bio"]),
InvitedAt: toTimeString(data["invited_at"]), Avatar: toString(data["avatar"]),
JoinedAt: toTimeString(data["joined_at"]), Email: toString(data["email"]),
LastActivity: toTimeString(data["last_activity"]), RoleID: toString(data["role_id"]),
CreatedAt: toTimeString(data["created_at"]), IsOwner: data["is_owner"], // Keep original type (int or bool)
UpdatedAt: toTimeString(data["updated_at"]), 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 // Add settings if available
@ -883,6 +1060,59 @@ func mapToMemberResponse(data maps.MapStr) MemberResponse {
func mapToMemberDetailResponse(data maps.MapStr) MemberDetailResponse { func mapToMemberDetailResponse(data maps.MapStr) MemberDetailResponse {
member := MemberDetailResponse{ member := MemberDetailResponse{
MemberResponse: mapToMemberResponse(data), 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) // Add user info if available (could be joined from user table)

View file

@ -394,25 +394,48 @@ type TeamSelectionRequest struct {
// MemberResponse represents a team member in API responses // MemberResponse represents a team member in API responses
type MemberResponse struct { type MemberResponse struct {
ID int64 `json:"id"` ID int64 `json:"id"`
TeamID string `json:"team_id"` MemberID string `json:"member_id,omitempty"`
UserID string `json:"user_id"` TeamID string `json:"team_id"`
MemberType string `json:"member_type"` UserID string `json:"user_id"`
RoleID string `json:"role_id"` MemberType string `json:"member_type"`
Status string `json:"status"` DisplayName string `json:"display_name,omitempty"`
InvitedBy string `json:"invited_by,omitempty"` Bio string `json:"bio,omitempty"`
InvitedAt string `json:"invited_at,omitempty"` Avatar string `json:"avatar,omitempty"`
JoinedAt string `json:"joined_at,omitempty"` Email string `json:"email,omitempty"`
LastActivity string `json:"last_activity,omitempty"` RoleID string `json:"role_id"`
Settings *MemberSettings `json:"settings,omitempty"` IsOwner interface{} `json:"is_owner,omitempty"` // Can be int or bool
CreatedAt string `json:"created_at"` Status string `json:"status"`
UpdatedAt string `json:"updated_at"` 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 // MemberDetailResponse represents detailed member information
type MemberDetailResponse struct { type MemberDetailResponse struct {
MemberResponse 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"` 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 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 // UpdateMemberRequest represents the request to update a member
type UpdateMemberRequest struct { type UpdateMemberRequest struct {
RoleID string `json:"role_id,omitempty"` RoleID string `json:"role_id,omitempty"`

View file

@ -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 // Security Utilities
// maskEmail masks an email address for privacy protection // maskEmail masks an email address for privacy protection