Merge pull request #1241 from trheyi/main

Update robot member functionality with new fields and validation
This commit is contained in:
Max 2025-10-27 11:35:01 +08:00 committed by GitHub
commit 3d0ae251ac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 531 additions and 278 deletions

File diff suppressed because one or more lines are too long

View file

@ -157,7 +157,7 @@ var (
// DefaultMemberFields contains basic member fields
DefaultMemberFields = []interface{}{
"member_id", "team_id", "user_id", "member_type", "display_name", "bio", "avatar", "email", "role_id", "is_owner", "status",
"member_id", "team_id", "user_id", "member_type", "display_name", "bio", "avatar", "email", "robot_email", "role_id", "is_owner", "status",
"invitation_id", "invited_by", "invited_at", "joined_at", "invitation_token", "invitation_expires_at",
"last_active_at", "login_count", "created_at", "updated_at",
}
@ -165,7 +165,8 @@ var (
// DefaultMemberDetailFields contains all member fields including robot config
DefaultMemberDetailFields = []interface{}{
"member_id", "team_id", "user_id", "member_type", "display_name", "bio", "avatar", "email", "role_id", "is_owner", "status",
"system_prompt", "manager_id", "robot_config", "agents", "mcp_servers",
"system_prompt", "manager_id", "robot_email", "authorized_senders", "email_filter_rules",
"robot_config", "agents", "mcp_servers",
"language_model", "cost_limit", "autonomous_mode", "last_robot_activity", "robot_status",
"invitation_id", "invited_by", "invited_at", "joined_at", "invitation_token",
"invitation_expires_at", "last_active_at",

View file

@ -164,14 +164,13 @@ func (u *DefaultUser) MemberExists(ctx context.Context, teamID string, userID st
return len(members) > 0, nil
}
// MemberExistsByTeamEmail checks if a member exists by team_id and email
func (u *DefaultUser) MemberExistsByTeamEmail(ctx context.Context, teamID string, email string) (bool, error) {
// MemberExistsByRobotEmail checks if a robot member exists by robot_email (globally unique)
func (u *DefaultUser) MemberExistsByRobotEmail(ctx context.Context, robotEmail string) (bool, error) {
m := model.Select(u.memberModel)
members, err := m.Get(model.QueryParam{
Select: []interface{}{"id"}, // Only select ID for existence check
Wheres: []model.QueryWhere{
{Column: "team_id", Value: teamID},
{Column: "email", Value: email},
{Column: "robot_email", Value: robotEmail},
},
Limit: 1,
})
@ -323,23 +322,22 @@ func (u *DefaultUser) CreateRobotMember(ctx context.Context, teamID string, robo
return "", fmt.Errorf("role_id is required for robot members")
}
// Check if email already exists in this team
if email, exists := robotData["email"]; exists && email != nil && email != "" {
emailStr := fmt.Sprintf("%v", email)
// Check if robot_email already exists globally (robot_email is globally unique)
if robotEmail, exists := robotData["robot_email"]; exists && robotEmail != nil && robotEmail != "" {
robotEmailStr := fmt.Sprintf("%v", robotEmail)
m := model.Select(u.memberModel)
existingMembers, err := m.Get(model.QueryParam{
Select: []interface{}{"id"},
Wheres: []model.QueryWhere{
{Column: "team_id", Value: teamID},
{Column: "email", Value: emailStr},
{Column: "robot_email", Value: robotEmailStr},
},
Limit: 1,
})
if err != nil {
return "", fmt.Errorf("failed to check email uniqueness: %w", err)
return "", fmt.Errorf("failed to check robot_email uniqueness: %w", err)
}
if len(existingMembers) > 0 {
return "", fmt.Errorf("email %s already exists in this team", emailStr)
return "", fmt.Errorf("robot_email %s already exists", robotEmailStr)
}
}
@ -362,7 +360,8 @@ func (u *DefaultUser) CreateRobotMember(ctx context.Context, teamID string, robo
// Copy robot-specific fields
robotFields := []string{
"role_id", "system_prompt", "manager_id", "robot_config", "agents", "mcp_servers",
"role_id", "system_prompt", "manager_id", "robot_email", "authorized_senders", "email_filter_rules",
"robot_config", "agents", "mcp_servers",
"language_model", "cost_limit", "autonomous_mode", "robot_status",
"notes", "metadata",
"__yao_created_by", "__yao_updated_by", "__yao_team_id", "__yao_tenant_id",

View file

@ -352,6 +352,15 @@ func TestRobotMemberOperations(t *testing.T) {
"language_model": "gpt-4",
"cost_limit": 100.00,
"manager_id": ownerUser,
"robot_email": "testbot" + testUUID + "@robot.example.com",
"authorized_senders": []string{
"admin@example.com",
"manager@example.com",
},
"email_filter_rules": []string{
".*@example\\.com$",
".*@test\\.com$",
},
"robot_config": map[string]interface{}{
"max_tokens": 1000,
},
@ -368,6 +377,13 @@ func TestRobotMemberOperations(t *testing.T) {
assert.Equal(t, "robot", member["member_type"])
assert.Equal(t, "active", member["status"]) // Robots are active by default
assert.Nil(t, member["user_id"]) // Robots don't have user_id
// Verify new email fields in detail view
memberDetail, err := testProvider.GetMemberDetailByMemberID(ctx, robotBusinessMemberID)
assert.NoError(t, err)
assert.Equal(t, "testbot"+testUUID+"@robot.example.com", memberDetail["robot_email"])
assert.NotNil(t, memberDetail["authorized_senders"])
assert.NotNil(t, memberDetail["email_filter_rules"])
})
// Test GetTeamRobotMembers
@ -1170,7 +1186,7 @@ func TestMemberIDOperations(t *testing.T) {
})
}
func TestMemberExistsByTeamEmail(t *testing.T) {
func TestMemberExistsByRobotEmail(t *testing.T) {
prepare(t)
defer clean()
@ -1184,9 +1200,9 @@ func TestMemberExistsByTeamEmail(t *testing.T) {
// Create test team
teamMap := maps.MapStrAny{
"name": "Email Test Team " + testUUID,
"display_name": "Email Test " + testUUID,
"description": "A test team for email testing",
"name": "Robot Email Test Team " + testUUID,
"display_name": "Robot Email Test " + testUUID,
"description": "A test team for robot email testing",
"owner_id": ownerUser,
"status": "active",
}
@ -1194,13 +1210,13 @@ func TestMemberExistsByTeamEmail(t *testing.T) {
teamID, err := testProvider.CreateTeam(ctx, teamMap)
assert.NoError(t, err)
testEmail := "test" + testUUID + "@example.com"
testRobotEmail := "testrobot" + testUUID + "@robot.example.com"
// Create robot member with email
t.Run("CreateRobotMemberWithEmail", func(t *testing.T) {
// Create robot member with robot_email
t.Run("CreateRobotMemberWithRobotEmail", func(t *testing.T) {
robotData := maps.MapStrAny{
"display_name": "TestBot" + testUUID,
"email": testEmail,
"robot_email": testRobotEmail,
"role_id": "bot",
}
@ -1209,19 +1225,184 @@ func TestMemberExistsByTeamEmail(t *testing.T) {
assert.NotEmpty(t, businessMemberID)
})
// Test MemberExistsByTeamEmail
t.Run("MemberExistsByTeamEmail_Exists", func(t *testing.T) {
exists, err := testProvider.MemberExistsByTeamEmail(ctx, teamID, testEmail)
// Test MemberExistsByRobotEmail
t.Run("MemberExistsByRobotEmail_Exists", func(t *testing.T) {
exists, err := testProvider.MemberExistsByRobotEmail(ctx, testRobotEmail)
assert.NoError(t, err)
assert.True(t, exists)
})
// Test with non-existent email
t.Run("MemberExistsByTeamEmail_NotExists", func(t *testing.T) {
exists, err := testProvider.MemberExistsByTeamEmail(ctx, teamID, "nonexistent@example.com")
// Test with non-existent robot email
t.Run("MemberExistsByRobotEmail_NotExists", func(t *testing.T) {
exists, err := testProvider.MemberExistsByRobotEmail(ctx, "nonexistent@robot.example.com")
assert.NoError(t, err)
assert.False(t, exists)
})
}
func TestRobotEmailUniqueness(t *testing.T) {
prepare(t)
defer clean()
ctx := context.Background()
// Use UUID to ensure unique identifiers
testUUID := strings.ReplaceAll(uuid.New().String(), "-", "")[:8]
// Create test user (team owner)
ownerUser := createTestUser(ctx, t, "owner"+testUUID)
// Create two test teams
team1Map := maps.MapStrAny{
"name": "Robot Email Test Team 1 " + testUUID,
"display_name": "Robot Email Test 1 " + testUUID,
"description": "First test team for robot email testing",
"owner_id": ownerUser,
"status": "active",
}
team1ID, err := testProvider.CreateTeam(ctx, team1Map)
assert.NoError(t, err)
team2Map := maps.MapStrAny{
"name": "Robot Email Test Team 2 " + testUUID,
"display_name": "Robot Email Test 2 " + testUUID,
"description": "Second test team for robot email testing",
"owner_id": ownerUser,
"status": "active",
}
team2ID, err := testProvider.CreateTeam(ctx, team2Map)
assert.NoError(t, err)
testEmail := "unique-robot" + testUUID + "@robot.example.com"
// Test creating first robot with robot_email
t.Run("CreateFirstRobotWithEmail", func(t *testing.T) {
robotData := maps.MapStrAny{
"display_name": "Robot1" + testUUID,
"role_id": "bot",
"robot_email": testEmail,
"authorized_senders": []string{
"admin@example.com",
},
"email_filter_rules": []string{
".*@example\\.com$",
},
}
memberID, err := testProvider.CreateRobotMember(ctx, team1ID, robotData)
assert.NoError(t, err)
assert.NotEmpty(t, memberID)
// Verify robot_email was set
member, err := testProvider.GetMemberDetailByMemberID(ctx, memberID)
assert.NoError(t, err)
assert.Equal(t, testEmail, member["robot_email"])
})
// Test creating second robot with same robot_email should fail (global uniqueness)
t.Run("CreateSecondRobotWithSameEmail_ShouldFail", func(t *testing.T) {
robotData := maps.MapStrAny{
"display_name": "Robot2" + testUUID,
"role_id": "bot",
"robot_email": testEmail, // Same email as first robot
}
_, err := testProvider.CreateRobotMember(ctx, team2ID, robotData)
assert.Error(t, err)
// The error should indicate uniqueness constraint violation
// Note: The exact error message may vary depending on the database driver
})
// Test creating robot with different robot_email should succeed
t.Run("CreateRobotWithDifferentEmail_ShouldSucceed", func(t *testing.T) {
differentEmail := "another-robot" + testUUID + "@robot.example.com"
robotData := maps.MapStrAny{
"display_name": "Robot3" + testUUID,
"role_id": "bot",
"robot_email": differentEmail,
}
memberID, err := testProvider.CreateRobotMember(ctx, team2ID, robotData)
assert.NoError(t, err)
assert.NotEmpty(t, memberID)
// Verify robot_email was set
member, err := testProvider.GetMemberDetailByMemberID(ctx, memberID)
assert.NoError(t, err)
assert.Equal(t, differentEmail, member["robot_email"])
})
// Test updating robot_email
t.Run("UpdateRobotEmail", func(t *testing.T) {
// Create a new robot
newEmail := "updatable-robot" + testUUID + "@robot.example.com"
robotData := maps.MapStrAny{
"display_name": "Robot4" + testUUID,
"role_id": "bot",
"robot_email": newEmail,
}
memberID, err := testProvider.CreateRobotMember(ctx, team1ID, robotData)
assert.NoError(t, err)
// Update robot_email
updatedEmail := "updated-robot" + testUUID + "@robot.example.com"
updateData := maps.MapStrAny{
"robot_email": updatedEmail,
}
err = testProvider.UpdateMemberByMemberID(ctx, memberID, updateData)
assert.NoError(t, err)
// Verify update
member, err := testProvider.GetMemberDetailByMemberID(ctx, memberID)
assert.NoError(t, err)
assert.Equal(t, updatedEmail, member["robot_email"])
})
// Test updating authorized_senders and email_filter_rules
t.Run("UpdateRobotEmailConfiguration", func(t *testing.T) {
// Create a new robot
robotData := maps.MapStrAny{
"display_name": "Robot5" + testUUID,
"role_id": "bot",
"robot_email": "config-robot" + testUUID + "@robot.example.com",
"authorized_senders": []string{
"initial@example.com",
},
"email_filter_rules": []string{
".*@initial\\.com$",
},
}
memberID, err := testProvider.CreateRobotMember(ctx, team1ID, robotData)
assert.NoError(t, err)
// Update email configuration
updateData := maps.MapStrAny{
"authorized_senders": []string{
"admin@example.com",
"manager@example.com",
"owner@example.com",
},
"email_filter_rules": []string{
".*@example\\.com$",
".*@test\\.com$",
".*@company\\.com$",
},
}
err = testProvider.UpdateMemberByMemberID(ctx, memberID, updateData)
assert.NoError(t, err)
// Verify update
member, err := testProvider.GetMemberDetailByMemberID(ctx, memberID)
assert.NoError(t, err)
assert.NotNil(t, member["authorized_senders"])
assert.NotNil(t, member["email_filter_rules"])
})
}
// Helper function createTestUser is defined in team_test.go

View file

@ -300,7 +300,7 @@ type UserProvider interface {
GetMemberDetailByMemberID(ctx context.Context, memberID string) (maps.MapStrAny, error)
GetMemberByInvitationID(ctx context.Context, invitationID string) (maps.MapStrAny, error)
MemberExists(ctx context.Context, teamID string, userID string) (bool, error)
MemberExistsByTeamEmail(ctx context.Context, teamID string, email string) (bool, error)
MemberExistsByRobotEmail(ctx context.Context, robotEmail string) (bool, error)
CreateMember(ctx context.Context, memberData maps.MapStrAny) (string, error)
UpdateMember(ctx context.Context, teamID string, userID string, memberData maps.MapStrAny) error
UpdateMemberByID(ctx context.Context, memberID int64, memberData maps.MapStrAny) error

View file

@ -1136,17 +1136,20 @@ func TestMemberCreateRobot(t *testing.T) {
"create robot with all fields",
teamID,
map[string]interface{}{
"name": "AI Assistant Full",
"email": fmt.Sprintf("ai-full-%s@test.com", testUUID),
"bio": "A comprehensive AI assistant",
"role": "member",
"report_to": tokenInfo.UserID,
"prompt": "You are a helpful AI assistant with full capabilities",
"llm": "gpt-4",
"agents": []string{"data-analyst", "code-reviewer"},
"mcp_tools": []string{"filesystem", "database"},
"autonomous_mode": "enabled",
"cost_limit": 100.50,
"name": "AI Assistant Full",
"email": fmt.Sprintf("ai-full-%s@test.com", testUUID),
"robot_email": fmt.Sprintf("robot-full-%s@robot.test.com", testUUID),
"authorized_senders": []string{"user1@test.com", "user2@test.com"},
"email_filter_rules": []string{".*@company\\.com", ".*@partner\\.com"},
"bio": "A comprehensive AI assistant",
"role": "member",
"report_to": tokenInfo.UserID,
"prompt": "You are a helpful AI assistant with full capabilities",
"llm": "gpt-4",
"agents": []string{"data-analyst", "code-reviewer"},
"mcp_tools": []string{"filesystem", "database"},
"autonomous_mode": "enabled",
"cost_limit": 100.50,
},
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
@ -1158,10 +1161,10 @@ func TestMemberCreateRobot(t *testing.T) {
"create robot with required fields only",
teamID,
map[string]interface{}{
"name": "AI Assistant Min",
"email": fmt.Sprintf("ai-min-%s@test.com", testUUID),
"role": "member",
"prompt": "You are a basic assistant",
"name": "AI Assistant Min",
"robot_email": fmt.Sprintf("ai-min-%s@test.com", testUUID),
"role": "member",
"prompt": "You are a basic assistant",
},
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
@ -1174,7 +1177,7 @@ func TestMemberCreateRobot(t *testing.T) {
teamID,
map[string]interface{}{
"name": "AI Assistant Auto",
"email": fmt.Sprintf("ai-auto-%s@test.com", testUUID),
"robot_email": fmt.Sprintf("ai-auto-%s@test.com", testUUID),
"role": "member",
"prompt": "You are an autonomous assistant",
"autonomous_mode": "1", // Test numeric string
@ -1190,7 +1193,7 @@ func TestMemberCreateRobot(t *testing.T) {
teamID,
map[string]interface{}{
"name": "AI Assistant Manual",
"email": fmt.Sprintf("ai-manual-%s@test.com", testUUID),
"robot_email": fmt.Sprintf("ai-manual-%s@test.com", testUUID),
"role": "member",
"prompt": "You are a manual assistant",
"autonomous_mode": "disabled",
@ -1205,9 +1208,9 @@ func TestMemberCreateRobot(t *testing.T) {
"create robot without name",
teamID,
map[string]interface{}{
"email": "no-name@test.com",
"role": "member",
"prompt": "You are an assistant",
"robot_email": "no-name@test.com",
"role": "member",
"prompt": "You are an assistant",
},
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
@ -1216,10 +1219,10 @@ func TestMemberCreateRobot(t *testing.T) {
"should require name",
},
{
"create robot without email",
"create robot without robot_email",
teamID,
map[string]interface{}{
"name": "No Email Robot",
"name": "No Robot Email Robot",
"role": "member",
"prompt": "You are an assistant",
},
@ -1227,15 +1230,15 @@ func TestMemberCreateRobot(t *testing.T) {
"Authorization": "Bearer " + tokenInfo.AccessToken,
},
400,
"should require email",
"should require robot_email",
},
{
"create robot without role",
teamID,
map[string]interface{}{
"name": "No Role Robot",
"email": "no-role@test.com",
"prompt": "You are an assistant",
"name": "No Role Robot",
"robot_email": "no-role@test.com",
"prompt": "You are an assistant",
},
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
@ -1247,9 +1250,9 @@ func TestMemberCreateRobot(t *testing.T) {
"create robot without prompt",
teamID,
map[string]interface{}{
"name": "No Prompt Robot",
"email": "no-prompt@test.com",
"role": "member",
"name": "No Prompt Robot",
"robot_email": "no-prompt@test.com",
"role": "member",
},
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
@ -1258,28 +1261,29 @@ func TestMemberCreateRobot(t *testing.T) {
"should require prompt",
},
{
"create robot with duplicate email",
"create robot with duplicate robot_email",
teamID,
map[string]interface{}{
"name": "Duplicate Email Robot",
"email": fmt.Sprintf("ai-full-%s@test.com", testUUID), // Same as first successful case
"role": "member",
"prompt": "You are an assistant",
"name": "Duplicate Robot Email Robot",
"email": fmt.Sprintf("duplicate-robot-%s@test.com", testUUID), // Different email
"robot_email": fmt.Sprintf("robot-full-%s@robot.test.com", testUUID), // Same robot_email as first successful case
"role": "member",
"prompt": "You are an assistant",
},
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
},
409,
"should reject duplicate email in same team",
"should reject duplicate robot_email globally",
},
{
"create robot in non-existent team",
"non-existent-team-id",
map[string]interface{}{
"name": "Robot in Void",
"email": "void@test.com",
"role": "member",
"prompt": "You are lost",
"name": "Robot in Void",
"robot_email": "void@test.com",
"role": "member",
"prompt": "You are lost",
},
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
@ -1394,8 +1398,8 @@ func toString(v interface{}) string {
}
}
// TestMemberCheckEmail tests the GET /user/teams/:team_id/members/check endpoint
func TestMemberCheckEmail(t *testing.T) {
// TestMemberCheckRobotEmail tests the GET /user/teams/:team_id/members/check-robot-email endpoint
func TestMemberCheckRobotEmail(t *testing.T) {
// Initialize test environment
serverURL := testutils.Prepare(t)
defer testutils.Clean()
@ -1407,7 +1411,7 @@ func TestMemberCheckEmail(t *testing.T) {
}
// Register a test client for OAuth authentication
testClient := testutils.RegisterTestClient(t, "Member Check Email Test Client", []string{"https://localhost/callback"})
testClient := testutils.RegisterTestClient(t, "Member Check Robot Email Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, testClient.ClientID)
// Obtain access token for authenticated requests
@ -1417,16 +1421,17 @@ func TestMemberCheckEmail(t *testing.T) {
testUUID := strings.ReplaceAll(uuid.New().String(), "-", "")[:8]
// Create a test team
createdTeam := createTestTeam(t, serverURL, baseURL, tokenInfo.AccessToken, "Email Check Test Team "+testUUID)
createdTeam := createTestTeam(t, serverURL, baseURL, tokenInfo.AccessToken, "Robot Email Check Test Team "+testUUID)
teamID := getTeamID(createdTeam)
// Create a robot member with a known email
existingEmail := fmt.Sprintf("existing-robot-%s@test.com", testUUID)
// Create a robot member with a known robot_email (globally unique)
existingRobotEmail := fmt.Sprintf("existing-robot-%s@robot.test.com", testUUID)
robotBody := map[string]interface{}{
"name": "Existing Robot",
"email": existingEmail,
"role": "member",
"prompt": "You are a test robot",
"name": "Existing Robot",
"email": fmt.Sprintf("display-%s@test.com", testUUID), // Display email (can be non-unique)
"robot_email": existingRobotEmail, // Globally unique robot email
"role": "member",
"prompt": "You are a test robot",
}
robotBodyBytes, _ := json.Marshal(robotBody)
robotReq, _ := http.NewRequest("POST", serverURL+baseURL+"/user/teams/"+teamID+"/members/robots", bytes.NewBuffer(robotBodyBytes))
@ -1443,45 +1448,45 @@ func TestMemberCheckEmail(t *testing.T) {
testCases := []struct {
name string
teamID string
email string
robotEmail string
headers map[string]string
expectCode int
expectExists bool
expectMsg string
}{
{
"check email without authentication",
"check robot email without authentication",
teamID,
existingEmail,
existingRobotEmail,
map[string]string{},
401,
false,
"should require authentication",
},
{
"check existing email",
"check existing robot email",
teamID,
existingEmail,
existingRobotEmail,
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
},
200,
true,
"should return exists=true for existing email",
"should return exists=true for existing robot email",
},
{
"check non-existing email",
"check non-existing robot email",
teamID,
fmt.Sprintf("nonexistent-%s@test.com", testUUID),
fmt.Sprintf("nonexistent-%s@robot.test.com", testUUID),
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
},
200,
false,
"should return exists=false for non-existing email",
"should return exists=false for non-existing robot email",
},
{
"check email without email parameter",
"check robot email without robot_email parameter",
teamID,
"",
map[string]string{
@ -1489,12 +1494,12 @@ func TestMemberCheckEmail(t *testing.T) {
},
400,
false,
"should require email parameter",
"should require robot_email parameter",
},
{
"check email in non-existent team",
"check robot email in non-existent team",
"non-existent-team-id",
"test@example.com",
"test@robot.example.com",
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
},
@ -1506,9 +1511,9 @@ func TestMemberCheckEmail(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
requestURL := serverURL + baseURL + "/user/teams/" + tc.teamID + "/members/check"
if tc.email != "" {
requestURL += "?email=" + tc.email
requestURL := serverURL + baseURL + "/user/teams/" + tc.teamID + "/members/check-robot-email"
if tc.robotEmail != "" {
requestURL += "?robot_email=" + tc.robotEmail
}
req, err := http.NewRequest("GET", requestURL, nil)
@ -1535,15 +1540,13 @@ func TestMemberCheckEmail(t *testing.T) {
err = json.Unmarshal(body, &response)
assert.NoError(t, err, "Should parse JSON response")
// Verify response structure
// Verify response structure (global check, no team_id in response)
assert.Contains(t, response, "exists", "Should have exists field")
assert.Contains(t, response, "email", "Should have email field")
assert.Contains(t, response, "team_id", "Should have team_id field")
assert.Contains(t, response, "robot_email", "Should have robot_email field")
// Verify values
assert.Equal(t, tc.expectExists, response["exists"], "Should have correct exists value")
assert.Equal(t, tc.email, response["email"], "Should have correct email")
assert.Equal(t, teamID, response["team_id"], "Should have correct team_id")
assert.Equal(t, tc.robotEmail, response["robot_email"], "Should have correct robot_email")
}
t.Logf("Member check email test %s: status=%d, body=%s", tc.name, resp.StatusCode, string(body))

View file

@ -118,8 +118,8 @@ func GinMemberList(c *gin.Context) {
response.RespondWithSuccess(c, http.StatusOK, result)
}
// GinMemberCheckEmail handles GET /api/user/teams/:id/members/check?email=xxx - Check if email exists in team
func GinMemberCheckEmail(c *gin.Context) {
// GinMemberCheckRobotEmail handles GET /api/user/teams/:id/members/check-robot-email?robot_email=xxx - Check if robot email exists globally
func GinMemberCheckRobotEmail(c *gin.Context) {
// Get authorized user info
authInfo := oauth.GetAuthorizedInfo(c)
if authInfo == nil || authInfo.UserID == "" {
@ -141,20 +141,20 @@ func GinMemberCheckEmail(c *gin.Context) {
return
}
email := c.Query("email")
if email == "" {
robotEmail := c.Query("robot_email")
if robotEmail == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Email query parameter is required",
ErrorDescription: "robot_email query parameter is required",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Call business logic
exists, err := memberCheckEmail(c.Request.Context(), authInfo.UserID, teamID, email)
exists, err := memberCheckRobotEmail(c.Request.Context(), authInfo.UserID, teamID, robotEmail)
if err != nil {
log.Error("Failed to check member email: %v", err)
log.Error("Failed to check robot email: %v", err)
// Check error type for appropriate response
if strings.Contains(err.Error(), "not found") {
errorResp := &response.ErrorResponse{
@ -171,7 +171,7 @@ func GinMemberCheckEmail(c *gin.Context) {
} else {
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: fmt.Sprintf("Failed to check member email: %v", err),
ErrorDescription: fmt.Sprintf("Failed to check robot email: %v", err),
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
}
@ -180,9 +180,8 @@ func GinMemberCheckEmail(c *gin.Context) {
// Return result
result := map[string]interface{}{
"exists": exists,
"email": email,
"team_id": teamID,
"exists": exists,
"robot_email": robotEmail,
}
response.RespondWithSuccess(c, http.StatusOK, result)
}
@ -280,7 +279,7 @@ func GinMemberCreateRobot(c *gin.Context) {
// Prepare base robot member data
baseData := maps.MapStrAny{
"display_name": req.Name,
"email": req.Email,
"robot_email": req.RobotEmail, // Required: globally unique email
"bio": req.Bio,
"role_id": req.RoleID,
"system_prompt": req.SystemPrompt,
@ -288,6 +287,15 @@ func GinMemberCreateRobot(c *gin.Context) {
}
// Add optional fields
if req.Email != "" {
baseData["email"] = req.Email // Optional: display-only email
}
if len(req.AuthorizedSenders) > 0 {
baseData["authorized_senders"] = req.AuthorizedSenders
}
if len(req.EmailFilterRules) > 0 {
baseData["email_filter_rules"] = req.EmailFilterRules
}
if req.ManagerID != "" {
baseData["manager_id"] = req.ManagerID
}
@ -857,8 +865,8 @@ func memberGet(ctx context.Context, userID, teamID, memberID string) (maps.MapSt
return memberData, nil
}
// memberCheckEmail handles the business logic for checking if member exists by team_id and email
func memberCheckEmail(ctx context.Context, userID, teamID, email string) (bool, error) {
// memberCheckRobotEmail handles the business logic for checking if robot email exists globally
func memberCheckRobotEmail(ctx context.Context, userID, teamID, robotEmail string) (bool, error) {
// Check if user has access to the team (read permission: owner or member)
isOwner, isMember, err := checkTeamAccess(ctx, teamID, userID)
if err != nil {
@ -876,10 +884,10 @@ func memberCheckEmail(ctx context.Context, userID, teamID, email string) (bool,
return false, fmt.Errorf("failed to get user provider: %w", err)
}
// Check if member exists by team_id and email
exists, err := provider.MemberExistsByTeamEmail(ctx, teamID, email)
// Check if robot email exists globally (not limited to team)
exists, err := provider.MemberExistsByRobotEmail(ctx, robotEmail)
if err != nil {
return false, fmt.Errorf("failed to check member existence: %w", err)
return false, fmt.Errorf("failed to check robot email existence: %w", err)
}
return exists, nil
@ -1011,6 +1019,7 @@ func mapToMemberResponse(data maps.MapStr) MemberResponse {
Bio: toString(data["bio"]),
Avatar: toString(data["avatar"]),
Email: toString(data["email"]),
RobotEmail: toString(data["robot_email"]), // Globally unique email for robot members
RoleID: toString(data["role_id"]),
IsOwner: data["is_owner"], // Keep original type (int or bool)
Status: toString(data["status"]),
@ -1071,6 +1080,36 @@ func mapToMemberDetailResponse(data maps.MapStr) MemberDetailResponse {
Notes: toString(data["notes"]),
}
// Handle authorized_senders array
if authorizedSenders, ok := data["authorized_senders"]; ok {
if sendersSlice, ok := authorizedSenders.([]interface{}); ok {
sendersList := make([]string, 0, len(sendersSlice))
for _, s := range sendersSlice {
if senderStr, ok := s.(string); ok {
sendersList = append(sendersList, senderStr)
}
}
member.AuthorizedSenders = sendersList
} else if sendersStrSlice, ok := authorizedSenders.([]string); ok {
member.AuthorizedSenders = sendersStrSlice
}
}
// Handle email_filter_rules array
if filterRules, ok := data["email_filter_rules"]; ok {
if rulesSlice, ok := filterRules.([]interface{}); ok {
rulesList := make([]string, 0, len(rulesSlice))
for _, r := range rulesSlice {
if ruleStr, ok := r.(string); ok {
rulesList = append(rulesList, ruleStr)
}
}
member.EmailFilterRules = rulesList
} else if rulesStrSlice, ok := filterRules.([]string); ok {
member.EmailFilterRules = rulesStrSlice
}
}
// Handle robot_config map
if robotConfig, ok := data["robot_config"]; ok {
if configMap, ok := robotConfig.(map[string]interface{}); ok {

View file

@ -403,6 +403,7 @@ type MemberResponse struct {
Bio string `json:"bio,omitempty"`
Avatar string `json:"avatar,omitempty"`
Email string `json:"email,omitempty"`
RobotEmail string `json:"robot_email,omitempty"` // Globally unique email for robot members
RoleID string `json:"role_id"`
IsOwner interface{} `json:"is_owner,omitempty"` // Can be int or bool
Status string `json:"status"`
@ -425,6 +426,8 @@ type MemberDetailResponse struct {
// Robot-specific fields (only for robot members)
SystemPrompt string `json:"system_prompt,omitempty"`
ManagerID string `json:"manager_id,omitempty"`
AuthorizedSenders []string `json:"authorized_senders,omitempty"` // Whitelist of emails authorized to send commands
EmailFilterRules []string `json:"email_filter_rules,omitempty"` // Email filtering rules (supports regex patterns)
RobotConfig map[string]interface{} `json:"robot_config,omitempty"`
Agents []string `json:"agents,omitempty"`
MCPServers []string `json:"mcp_servers,omitempty"`
@ -441,17 +444,20 @@ type MemberDetailResponse struct {
// CreateRobotMemberRequest represents the request to create a new robot member
type CreateRobotMemberRequest struct {
Name string `json:"name" binding:"required"` // Display name
Email string `json:"email" binding:"required"` // Email address
Bio string `json:"bio,omitempty"` // Bio/description
RoleID string `json:"role" binding:"required"` // Role ID
ManagerID string `json:"report_to,omitempty"` // Direct manager user ID
SystemPrompt string `json:"prompt" binding:"required"` // Identity & role prompt
LanguageModel string `json:"llm,omitempty"` // Language model (e.g., "gpt-4")
Agents []string `json:"agents,omitempty"` // Accessible agents
MCPServers []string `json:"mcp_tools,omitempty"` // MCP servers/tools
AutonomousMode string `json:"autonomous_mode,omitempty"` // "enabled" or "disabled"
CostLimit float64 `json:"cost_limit,omitempty"` // Monthly cost limit in USD
Name string `json:"name" binding:"required"` // Display name
Email string `json:"email,omitempty"` // Email address (optional, for display only)
RobotEmail string `json:"robot_email" binding:"required"` // Robot's globally unique email address (required)
AuthorizedSenders []string `json:"authorized_senders,omitempty"` // Whitelist of emails authorized to send commands
EmailFilterRules []string `json:"email_filter_rules,omitempty"` // Email filtering rules (supports regex patterns)
Bio string `json:"bio,omitempty"` // Bio/description
RoleID string `json:"role" binding:"required"` // Role ID
ManagerID string `json:"report_to,omitempty"` // Direct manager user ID
SystemPrompt string `json:"prompt" binding:"required"` // Identity & role prompt
LanguageModel string `json:"llm,omitempty"` // Language model (e.g., "gpt-4")
Agents []string `json:"agents,omitempty"` // Accessible agents
MCPServers []string `json:"mcp_tools,omitempty"` // MCP servers/tools
AutonomousMode string `json:"autonomous_mode,omitempty"` // "enabled" or "disabled"
CostLimit float64 `json:"cost_limit,omitempty"` // Monthly cost limit in USD
}
// MemberListRequest represents the request to list team members with advanced filtering

View file

@ -133,12 +133,12 @@ func attachTeam(group *gin.RouterGroup, oauth types.OAuth) {
team.GET("/current", GinTeamCurrent)
// Team Members - Nested resource endpoints
team.GET("/:id/members", GinMemberList) // GET /api/user/teams/:id/members - List team members
team.GET("/:id/members/check", GinMemberCheckEmail) // GET /api/user/teams/:id/members/check?email=xxx - Check if email exists in team
team.POST("/:id/members/robots", GinMemberCreateRobot) // POST /api/user/teams/:id/members/robots - Add robot member
team.GET("/:id/members/:member_id", GinMemberGet) // GET /api/user/teams/:id/members/:member_id - Get member details
team.PUT("/:id/members/:member_id", GinMemberUpdate) // PUT /api/user/teams/:id/members/:member_id - Update member
team.DELETE("/:id/members/:member_id", GinMemberDelete) // DELETE /api/user/teams/:id/members/:member_id - Remove member
team.GET("/:id/members", GinMemberList) // GET /api/user/teams/:id/members - List team members
team.GET("/:id/members/check-robot-email", GinMemberCheckRobotEmail) // GET /api/user/teams/:id/members/check-robot-email?robot_email=xxx - Check if robot email exists globally
team.POST("/:id/members/robots", GinMemberCreateRobot) // POST /api/user/teams/:id/members/robots - Add robot member
team.GET("/:id/members/:member_id", GinMemberGet) // GET /api/user/teams/:id/members/:member_id - Get member details
team.PUT("/:id/members/:member_id", GinMemberUpdate) // PUT /api/user/teams/:id/members/:member_id - Update member
team.DELETE("/:id/members/:member_id", GinMemberDelete) // DELETE /api/user/teams/:id/members/:member_id - Remove member
// Team Invitations - Nested resource endpoints
team.GET("/:id/invitations", GinTeamInvitationList) // GET /teams/:id/invitations - List invitations

View file

@ -161,6 +161,30 @@
// ============================================================================
// Robot Configuration Fields (only for robot members)
// ============================================================================
{
"name": "robot_email",
"type": "string",
"label": "Robot Email",
"comment": "Globally unique email address for the robot to receive and send emails",
"length": 255,
"nullable": true,
"unique": true,
"index": true
},
{
"name": "authorized_senders",
"type": "json",
"label": "Authorized Senders",
"comment": "Whitelist of email addresses authorized to send instructions to this robot. Robot will respond to and execute commands from these senders only (JSON array)",
"nullable": true
},
{
"name": "email_filter_rules",
"type": "json",
"label": "Email Filter Rules",
"comment": "Email filtering rules (supports regex patterns) to determine which emails to receive and process. Can include domain patterns, address patterns, etc. (JSON array)",
"nullable": true
},
{
"name": "robot_config",
"type": "json",
@ -337,10 +361,10 @@
"comment": "Unique constraint: one user can have only one membership per team, with unique invitation_id for pending invitations"
},
{
"name": "idx_team_email_unique",
"name": "idx_team_email",
"columns": ["team_id", "email"],
"type": "unique",
"comment": "Unique constraint: one email per team (used for communication, applies to both users and robots)"
"type": "index",
"comment": "Index for querying members by email within team (email is for display only)"
},
{
"name": "idx_team_member_type_role",