diff --git a/openapi/tests/user/member_test.go b/openapi/tests/user/member_test.go index 82d47546..10dd8db7 100644 --- a/openapi/tests/user/member_test.go +++ b/openapi/tests/user/member_test.go @@ -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 } } diff --git a/openapi/user/member.go b/openapi/user/member.go index e7cb40d9..48b67a39 100644 --- a/openapi/user/member.go +++ b/openapi/user/member.go @@ -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) diff --git a/openapi/user/types.go b/openapi/user/types.go index a01cf5cb..5f2a83c7 100644 --- a/openapi/user/types.go +++ b/openapi/user/types.go @@ -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"` diff --git a/openapi/user/utils.go b/openapi/user/utils.go index e5a763fb..c6d8d957 100644 --- a/openapi/user/utils.go +++ b/openapi/user/utils.go @@ -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