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:
Max 2025-10-26 14:10:04 +08:00
parent 420a17f844
commit 772c00e095
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")
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
}
}

View file

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

View file

@ -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"`

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