Enhance member management with member_id and email checks

- Introduced member_id as a unique identifier for members, improving data integrity and consistency across member operations.
- Updated member-related functions to utilize member_id instead of internal IDs, enhancing API usability and clarity.
- Implemented email existence checks for team members, allowing for better validation during member creation and updates.
- Refactored existing tests to accommodate changes in member identification and email validation, ensuring comprehensive coverage and reliability.
- Added new API endpoint for checking if an email exists within a team, improving user experience during member management.
This commit is contained in:
Max 2025-10-26 09:49:45 +08:00
parent fd10a058b1
commit 5f6f8d7911
13 changed files with 973 additions and 231 deletions

File diff suppressed because one or more lines are too long

View file

@ -157,14 +157,14 @@ var (
// DefaultMemberFields contains basic member fields
DefaultMemberFields = []interface{}{
"team_id", "user_id", "member_type", "display_name", "bio", "avatar", "email", "role_id", "status",
"member_id", "team_id", "user_id", "member_type", "display_name", "bio", "avatar", "email", "role_id", "status",
"invitation_id", "invited_by", "invited_at", "joined_at", "invitation_token", "invitation_expires_at",
"last_active_at", "login_count", "created_at", "updated_at",
}
// DefaultMemberDetailFields contains all member fields including robot config and permissions
DefaultMemberDetailFields = []interface{}{
"team_id", "user_id", "member_type", "display_name", "bio", "avatar", "email", "role_id", "status",
"member_id", "team_id", "user_id", "member_type", "display_name", "bio", "avatar", "email", "role_id", "status",
"system_prompt", "manager_id", "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",

View file

@ -101,6 +101,50 @@ func (u *DefaultUser) GetMemberByInvitationID(ctx context.Context, invitationID
return members[0], nil
}
// GetMemberByMemberID retrieves member information by member_id (business ID)
func (u *DefaultUser) GetMemberByMemberID(ctx context.Context, memberID string) (maps.MapStrAny, error) {
m := model.Select(u.memberModel)
members, err := m.Get(model.QueryParam{
Select: u.memberFields,
Wheres: []model.QueryWhere{
{Column: "member_id", Value: memberID},
},
Limit: 1,
})
if err != nil {
return nil, fmt.Errorf(ErrFailedToGetMember, err)
}
if len(members) == 0 {
return nil, fmt.Errorf(ErrMemberNotFound)
}
return members[0], nil
}
// GetMemberDetailByMemberID retrieves detailed member information by member_id (business ID)
func (u *DefaultUser) GetMemberDetailByMemberID(ctx context.Context, memberID string) (maps.MapStrAny, error) {
m := model.Select(u.memberModel)
members, err := m.Get(model.QueryParam{
Select: u.memberDetailFields,
Wheres: []model.QueryWhere{
{Column: "member_id", Value: memberID},
},
Limit: 1,
})
if err != nil {
return nil, fmt.Errorf(ErrFailedToGetMember, err)
}
if len(members) == 0 {
return nil, fmt.Errorf(ErrMemberNotFound)
}
return members[0], nil
}
// MemberExists checks if a member exists by team_id and user_id
func (u *DefaultUser) MemberExists(ctx context.Context, teamID string, userID string) (bool, error) {
m := model.Select(u.memberModel)
@ -120,14 +164,46 @@ 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) {
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},
},
Limit: 1,
})
if err != nil {
return false, fmt.Errorf(ErrFailedToGetMember, err)
}
return len(members) > 0, nil
}
// CreateMember creates a new team member (user type)
func (u *DefaultUser) CreateMember(ctx context.Context, memberData maps.MapStrAny) (int64, error) {
func (u *DefaultUser) CreateMember(ctx context.Context, memberData maps.MapStrAny) (string, error) {
// Validate required fields for user members
if _, exists := memberData["team_id"]; !exists {
return 0, fmt.Errorf("team_id is required in memberData")
return "", fmt.Errorf("team_id is required in memberData")
}
if _, exists := memberData["role_id"]; !exists {
return 0, fmt.Errorf("role_id is required in memberData")
return "", fmt.Errorf("role_id is required in memberData")
}
// Generate member_id if not provided
var generatedMemberID string
if _, exists := memberData["member_id"]; !exists || memberData["member_id"] == nil || memberData["member_id"] == "" {
memberID, err := u.generateMemberIDWithRetry(ctx)
if err != nil {
return "", fmt.Errorf("failed to generate member ID: %w", err)
}
memberData["member_id"] = memberID
generatedMemberID = memberID
} else {
generatedMemberID = memberData["member_id"].(string)
}
// Add __yao_team_id to the member data
@ -147,7 +223,7 @@ func (u *DefaultUser) CreateMember(ctx context.Context, memberData maps.MapStrAn
if memberType == "user" && status != "pending" {
if _, exists := memberData["user_id"]; !exists {
return 0, fmt.Errorf("user_id is required for active user members")
return "", fmt.Errorf("user_id is required for active user members")
}
}
@ -155,7 +231,7 @@ func (u *DefaultUser) CreateMember(ctx context.Context, memberData maps.MapStrAn
if status == "pending" && memberData["invitation_id"] == nil {
invitationID, err := u.generateInvitationID()
if err != nil {
return 0, fmt.Errorf("failed to generate invitation ID: %w", err)
return "", fmt.Errorf("failed to generate invitation ID: %w", err)
}
memberData["invitation_id"] = invitationID
}
@ -175,22 +251,22 @@ func (u *DefaultUser) CreateMember(ctx context.Context, memberData maps.MapStrAn
}
m := model.Select(u.memberModel)
id, err := m.Create(memberData)
_, err := m.Create(memberData)
if err != nil {
return 0, fmt.Errorf(ErrFailedToCreateMember, err)
return "", fmt.Errorf(ErrFailedToCreateMember, err)
}
return int64(id), nil
return generatedMemberID, nil
}
// CreateRobotMember creates a new robot member
func (u *DefaultUser) CreateRobotMember(ctx context.Context, teamID string, robotData maps.MapStrAny) (int64, error) {
func (u *DefaultUser) CreateRobotMember(ctx context.Context, teamID string, robotData maps.MapStrAny) (string, error) {
// Validate required fields for robot members
if _, exists := robotData["display_name"]; !exists {
return 0, fmt.Errorf("display_name is required for robot members")
return "", fmt.Errorf("display_name is required for robot members")
}
if _, exists := robotData["role_id"]; !exists {
return 0, fmt.Errorf("role_id is required for robot members")
return "", fmt.Errorf("role_id is required for robot members")
}
// Check if email already exists in this team
@ -206,10 +282,10 @@ func (u *DefaultUser) CreateRobotMember(ctx context.Context, teamID string, robo
Limit: 1,
})
if err != nil {
return 0, fmt.Errorf("failed to check email uniqueness: %w", err)
return "", fmt.Errorf("failed to check email uniqueness: %w", err)
}
if len(existingMembers) > 0 {
return 0, fmt.Errorf("email %s already exists in this team", emailStr)
return "", fmt.Errorf("email %s already exists in this team", emailStr)
}
}
@ -256,20 +332,20 @@ func (u *DefaultUser) CreateRobotMember(ctx context.Context, teamID string, robo
}
// AddMember adds a user to a team (invitation-based)
func (u *DefaultUser) AddMember(ctx context.Context, teamID string, userID string, roleID string, invitedBy string) (int64, error) {
func (u *DefaultUser) AddMember(ctx context.Context, teamID string, userID string, roleID string, invitedBy string) (string, error) {
// Check if member already exists
exists, err := u.MemberExists(ctx, teamID, userID)
if err != nil {
return 0, fmt.Errorf("failed to check member existence: %w", err)
return "", fmt.Errorf("failed to check member existence: %w", err)
}
if exists {
return 0, fmt.Errorf("user is already a member of this team")
return "", fmt.Errorf("user is already a member of this team")
}
// Generate invitation token
token, err := generateRandomPassword(32) // Use existing password generation for token
if err != nil {
return 0, fmt.Errorf("failed to generate invitation token: %w", err)
return "", fmt.Errorf("failed to generate invitation token: %w", err)
}
memberData := maps.MapStrAny{
@ -370,7 +446,7 @@ func (u *DefaultUser) AcceptInvitation(ctx context.Context, invitationID string,
// UpdateMember updates an existing member
func (u *DefaultUser) UpdateMember(ctx context.Context, teamID string, userID string, memberData maps.MapStrAny) error {
// Remove sensitive fields that should not be updated directly
sensitiveFields := []string{"id", "team_id", "user_id", "created_at", "invitation_token"}
sensitiveFields := []string{"id", "member_id", "team_id", "user_id", "created_at", "invitation_token"}
for _, field := range sensitiveFields {
delete(memberData, field)
}
@ -403,7 +479,7 @@ func (u *DefaultUser) UpdateMember(ctx context.Context, teamID string, userID st
// UpdateMemberByID updates a member by internal ID
func (u *DefaultUser) UpdateMemberByID(ctx context.Context, memberID int64, memberData maps.MapStrAny) error {
// Remove sensitive fields that should not be updated directly
sensitiveFields := []string{"id", "team_id", "user_id", "created_at", "invitation_token"}
sensitiveFields := []string{"id", "member_id", "team_id", "user_id", "created_at", "invitation_token"}
for _, field := range sensitiveFields {
delete(memberData, field)
}
@ -432,6 +508,38 @@ func (u *DefaultUser) UpdateMemberByID(ctx context.Context, memberID int64, memb
return nil
}
// UpdateMemberByMemberID updates a member by member_id (business ID)
func (u *DefaultUser) UpdateMemberByMemberID(ctx context.Context, memberID string, memberData maps.MapStrAny) error {
// Remove sensitive fields that should not be updated directly
sensitiveFields := []string{"id", "member_id", "team_id", "user_id", "created_at", "invitation_token"}
for _, field := range sensitiveFields {
delete(memberData, field)
}
// Skip update if no valid fields remain
if len(memberData) == 0 {
return nil
}
m := model.Select(u.memberModel)
affected, err := m.UpdateWhere(model.QueryParam{
Wheres: []model.QueryWhere{
{Column: "member_id", Value: memberID},
},
Limit: 1,
}, memberData)
if err != nil {
return fmt.Errorf(ErrFailedToUpdateMember, err)
}
if affected == 0 {
return fmt.Errorf(ErrMemberNotFound)
}
return nil
}
// RemoveMember removes a member from a team (soft delete)
func (u *DefaultUser) RemoveMember(ctx context.Context, teamID string, userID string) error {
m := model.Select(u.memberModel)
@ -454,6 +562,27 @@ func (u *DefaultUser) RemoveMember(ctx context.Context, teamID string, userID st
return nil
}
// RemoveMemberByMemberID removes a member by member_id (business ID, soft delete)
func (u *DefaultUser) RemoveMemberByMemberID(ctx context.Context, memberID string) error {
m := model.Select(u.memberModel)
affected, err := m.DeleteWhere(model.QueryParam{
Wheres: []model.QueryWhere{
{Column: "member_id", Value: memberID},
},
Limit: 1,
})
if err != nil {
return fmt.Errorf(ErrFailedToDeleteMember, err)
}
if affected == 0 {
return fmt.Errorf(ErrMemberNotFound)
}
return nil
}
// RemoveAllTeamMembers removes all members from a team (used when deleting team)
func (u *DefaultUser) RemoveAllTeamMembers(ctx context.Context, teamID string) error {
m := model.Select(u.memberModel)
@ -621,6 +750,47 @@ func (u *DefaultUser) UpdateMemberLastActivity(ctx context.Context, teamID strin
return u.UpdateMember(ctx, teamID, userID, updateData)
}
// UpdateMemberRoleByMemberID updates a member's role by member_id
func (u *DefaultUser) UpdateMemberRoleByMemberID(ctx context.Context, memberID string, roleID string) error {
updateData := maps.MapStrAny{
"role_id": roleID,
}
return u.UpdateMemberByMemberID(ctx, memberID, updateData)
}
// UpdateMemberStatusByMemberID updates a member's status by member_id
func (u *DefaultUser) UpdateMemberStatusByMemberID(ctx context.Context, memberID string, status string) error {
updateData := maps.MapStrAny{
"status": status,
}
return u.UpdateMemberByMemberID(ctx, memberID, updateData)
}
// UpdateMemberLastActivityByMemberID updates a member's last activity time by member_id
func (u *DefaultUser) UpdateMemberLastActivityByMemberID(ctx context.Context, memberID string) error {
updateData := maps.MapStrAny{
"last_active_at": time.Now(),
}
// Also increment login count
member, err := u.GetMemberByMemberID(ctx, memberID)
if err != nil {
return err
}
loginCount := int64(0)
if count := member["login_count"]; count != nil {
if parsedCount, err := parseIntFromDB(count); err == nil {
loginCount = parsedCount
}
}
updateData["login_count"] = loginCount + 1
return u.UpdateMemberByMemberID(ctx, memberID, updateData)
}
// UpdateRobotActivity updates robot member's last activity and status
func (u *DefaultUser) UpdateRobotActivity(ctx context.Context, memberID int64, robotStatus string) error {
updateData := maps.MapStrAny{

View file

@ -40,7 +40,7 @@ func TestMemberBasicOperations(t *testing.T) {
teamID, err := testProvider.CreateTeam(ctx, teamMap)
assert.NoError(t, err)
var memberID int64
var businessMemberID string
// Test CreateMember
t.Run("CreateMember", func(t *testing.T) {
@ -52,10 +52,10 @@ func TestMemberBasicOperations(t *testing.T) {
"status": "active",
}
id, err := testProvider.CreateMember(ctx, memberData)
memberID, err := testProvider.CreateMember(ctx, memberData)
assert.NoError(t, err)
assert.Greater(t, id, int64(0))
memberID = id
assert.NotEmpty(t, memberID)
businessMemberID = memberID
})
// Test GetMember
@ -81,13 +81,15 @@ func TestMemberBasicOperations(t *testing.T) {
assert.Contains(t, member, "updated_at")
})
// Test GetMemberByID
t.Run("GetMemberByID", func(t *testing.T) {
member, err := testProvider.GetMemberByID(ctx, memberID)
// Test GetMemberByMemberID
t.Run("GetMemberByMemberID", func(t *testing.T) {
member, err := testProvider.GetMemberByMemberID(ctx, businessMemberID)
assert.NoError(t, err)
assert.NotNil(t, member)
assert.Equal(t, teamID, member["team_id"])
assert.Equal(t, memberUser, member["user_id"])
// Verify member_id is returned
assert.Equal(t, businessMemberID, member["member_id"])
})
// Test MemberExists
@ -129,22 +131,22 @@ func TestMemberBasicOperations(t *testing.T) {
assert.NoError(t, err) // Should not error, just ignore sensitive fields
})
// Test UpdateMemberByID
t.Run("UpdateMemberByID", func(t *testing.T) {
// Test UpdateMemberByMemberID
t.Run("UpdateMemberByMemberID", func(t *testing.T) {
updateData := maps.MapStrAny{
"status": "inactive",
}
err := testProvider.UpdateMemberByID(ctx, memberID, updateData)
err := testProvider.UpdateMemberByMemberID(ctx, businessMemberID, updateData)
assert.NoError(t, err)
// Verify update
member, err := testProvider.GetMemberByID(ctx, memberID)
member, err := testProvider.GetMemberByMemberID(ctx, businessMemberID)
assert.NoError(t, err)
assert.Equal(t, "inactive", member["status"])
// Change back to active for other tests
err = testProvider.UpdateMemberByID(ctx, memberID, maps.MapStrAny{"status": "active"})
err = testProvider.UpdateMemberByMemberID(ctx, businessMemberID, maps.MapStrAny{"status": "active"})
assert.NoError(t, err)
})
@ -249,7 +251,7 @@ func TestMemberInvitationFlow(t *testing.T) {
t.Run("AddMember", func(t *testing.T) {
memberID, err := testProvider.AddMember(ctx, teamID, inviteeUser, "user", ownerUser)
assert.NoError(t, err)
assert.Greater(t, memberID, int64(0))
assert.NotEmpty(t, memberID)
// Verify member was created with pending status
member, err := testProvider.GetMember(ctx, teamID, inviteeUser)
@ -335,7 +337,7 @@ func TestRobotMemberOperations(t *testing.T) {
teamID, err := testProvider.CreateTeam(ctx, teamMap)
assert.NoError(t, err)
var robotMemberID int64
var robotBusinessMemberID string
// Test CreateRobotMember
t.Run("CreateRobotMember", func(t *testing.T) {
@ -355,13 +357,13 @@ func TestRobotMemberOperations(t *testing.T) {
},
}
id, err := testProvider.CreateRobotMember(ctx, teamID, robotData)
memberID, err := testProvider.CreateRobotMember(ctx, teamID, robotData)
assert.NoError(t, err)
assert.Greater(t, id, int64(0))
robotMemberID = id
assert.NotEmpty(t, memberID)
robotBusinessMemberID = memberID
// Verify robot member was created
member, err := testProvider.GetMemberByID(ctx, robotMemberID)
member, err := testProvider.GetMemberByMemberID(ctx, robotBusinessMemberID)
assert.NoError(t, err)
assert.Equal(t, "robot", member["member_type"])
assert.Equal(t, "active", member["status"]) // Robots are active by default
@ -378,17 +380,30 @@ func TestRobotMemberOperations(t *testing.T) {
assert.Equal(t, "A test robot for unit testing", robots[0]["bio"])
})
// Test UpdateRobotActivity
// Test UpdateRobotActivity - needs internal database ID
t.Run("UpdateRobotActivity", func(t *testing.T) {
err := testProvider.UpdateRobotActivity(ctx, robotMemberID, "working")
assert.NoError(t, err)
// Verify robot activity was updated (use GetMemberDetail for full fields)
// First get team_id for the robot
member, err := testProvider.GetMemberByID(ctx, robotMemberID)
// Get internal database ID from member_id
member, err := testProvider.GetMemberByMemberID(ctx, robotBusinessMemberID)
assert.NoError(t, err)
robotTeamID := member["team_id"].(string)
// UpdateRobotActivity still uses internal database ID
// We need to query the database to get it
m := model.Select("__yao.member")
result, err := m.Get(model.QueryParam{
Select: []interface{}{"id"},
Wheres: []model.QueryWhere{
{Column: "member_id", Value: robotBusinessMemberID},
},
Limit: 1,
})
assert.NoError(t, err)
assert.Len(t, result, 1)
robotDBID := result[0]["id"].(int64)
err = testProvider.UpdateRobotActivity(ctx, robotDBID, "working")
assert.NoError(t, err)
// Get robot members to verify status (robot members don't have user_id)
robots, err := testProvider.GetTeamRobotMembers(ctx, robotTeamID)
assert.NoError(t, err)
@ -401,7 +416,7 @@ func TestRobotMemberOperations(t *testing.T) {
// Test GetActiveRobotMembers
t.Run("GetActiveRobotMembers", func(t *testing.T) {
// First make sure our robot is active
err := testProvider.UpdateMemberByID(ctx, robotMemberID, maps.MapStrAny{
err := testProvider.UpdateMemberByMemberID(ctx, robotBusinessMemberID, maps.MapStrAny{
"autonomous_mode": true,
"status": "active",
})
@ -741,11 +756,11 @@ func TestMemberInvitationExpiry(t *testing.T) {
"invitation_expires_at": expiredTime, // Expired 2 hours ago
}
memberID, err := testProvider.CreateMember(ctx, memberData)
businessMemberID, err := testProvider.CreateMember(ctx, memberData)
assert.NoError(t, err)
// Get the invitation_id
member, err := testProvider.GetMemberByID(ctx, memberID)
member, err := testProvider.GetMemberByMemberID(ctx, businessMemberID)
assert.NoError(t, err)
invitationID := member["invitation_id"].(string)
assert.NotEmpty(t, invitationID)
@ -799,12 +814,12 @@ func TestMemberInvitationIDOperations(t *testing.T) {
"invited_by": ownerUser,
}
memberID, err := testProvider.CreateMember(ctx, memberData)
businessMemberID, err := testProvider.CreateMember(ctx, memberData)
assert.NoError(t, err)
assert.Greater(t, memberID, int64(0))
assert.NotEmpty(t, businessMemberID)
// Get the created member to verify invitation_id was generated
member, err := testProvider.GetMemberByID(ctx, memberID)
member, err := testProvider.GetMemberByMemberID(ctx, businessMemberID)
assert.NoError(t, err)
assert.NotNil(t, member["invitation_id"])
assert.NotEmpty(t, member["invitation_id"])
@ -937,11 +952,11 @@ func TestCreateMemberInvitationIDGeneration(t *testing.T) {
"invited_by": ownerUser,
}
memberID, err := testProvider.CreateMember(ctx, memberData)
businessMemberID, err := testProvider.CreateMember(ctx, memberData)
assert.NoError(t, err)
// Get the created member
member, err := testProvider.GetMemberByID(ctx, memberID)
member, err := testProvider.GetMemberByMemberID(ctx, businessMemberID)
assert.NoError(t, err)
// Verify invitation_id was generated
@ -966,11 +981,11 @@ func TestCreateMemberInvitationIDGeneration(t *testing.T) {
"status": "active",
}
memberID, err := testProvider.CreateMember(ctx, memberData)
businessMemberID, err := testProvider.CreateMember(ctx, memberData)
assert.NoError(t, err)
// Get the created member
member, err := testProvider.GetMemberByID(ctx, memberID)
member, err := testProvider.GetMemberByMemberID(ctx, businessMemberID)
assert.NoError(t, err)
// Verify invitation_id is nil for active members
@ -991,11 +1006,11 @@ func TestCreateMemberInvitationIDGeneration(t *testing.T) {
"invitation_id": explicitInvitationID,
}
memberID, err := testProvider.CreateMember(ctx, memberData)
businessMemberID, err := testProvider.CreateMember(ctx, memberData)
assert.NoError(t, err)
// Get the created member
member, err := testProvider.GetMemberByID(ctx, memberID)
member, err := testProvider.GetMemberByMemberID(ctx, businessMemberID)
assert.NoError(t, err)
// Verify explicit invitation_id was preserved
@ -1003,4 +1018,215 @@ func TestCreateMemberInvitationIDGeneration(t *testing.T) {
})
}
func TestMemberIDOperations(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 users
ownerUser := createTestUser(ctx, t, "owner"+testUUID)
memberUser := createTestUser(ctx, t, "member"+testUUID)
// Create test team
teamMap := maps.MapStrAny{
"name": "Member ID Test Team " + testUUID,
"display_name": "Member ID Test " + testUUID,
"description": "A test team for member_id testing",
"owner_id": ownerUser,
"status": "active",
"type": "corporation",
"type_id": "business",
"metadata": map[string]interface{}{"test": true},
}
teamID, err := testProvider.CreateTeam(ctx, teamMap)
assert.NoError(t, err)
var businessMemberID string
// Test CreateMember generates member_id
t.Run("CreateMember_GeneratesMemberID", func(t *testing.T) {
memberData := maps.MapStrAny{
"team_id": teamID,
"user_id": memberUser,
"member_type": "user",
"role_id": "user",
"status": "active",
}
_, err := testProvider.CreateMember(ctx, memberData)
assert.NoError(t, err)
// Get the created member to verify member_id was generated
member, err := testProvider.GetMember(ctx, teamID, memberUser)
assert.NoError(t, err)
assert.NotNil(t, member["member_id"])
assert.NotEmpty(t, member["member_id"])
businessMemberID = member["member_id"].(string)
t.Logf("Generated member_id: %s", businessMemberID)
})
// Test GetMemberByMemberID
t.Run("GetMemberByMemberID", func(t *testing.T) {
member, err := testProvider.GetMemberByMemberID(ctx, businessMemberID)
assert.NoError(t, err)
assert.NotNil(t, member)
assert.Equal(t, businessMemberID, member["member_id"])
assert.Equal(t, teamID, member["team_id"])
assert.Equal(t, memberUser, member["user_id"])
})
// Test GetMemberDetailByMemberID
t.Run("GetMemberDetailByMemberID", func(t *testing.T) {
member, err := testProvider.GetMemberDetailByMemberID(ctx, businessMemberID)
assert.NoError(t, err)
assert.NotNil(t, member)
assert.Equal(t, businessMemberID, member["member_id"])
assert.Equal(t, teamID, member["team_id"])
// Should contain detailed fields
assert.Contains(t, member, "created_at")
assert.Contains(t, member, "updated_at")
})
// Test UpdateMemberByMemberID
t.Run("UpdateMemberByMemberID", func(t *testing.T) {
updateData := maps.MapStrAny{
"role_id": "admin",
"notes": "Updated via member_id",
}
err := testProvider.UpdateMemberByMemberID(ctx, businessMemberID, updateData)
assert.NoError(t, err)
// Verify update
member, err := testProvider.GetMemberByMemberID(ctx, businessMemberID)
assert.NoError(t, err)
assert.Equal(t, "admin", member["role_id"])
})
// Test UpdateMemberRoleByMemberID
t.Run("UpdateMemberRoleByMemberID", func(t *testing.T) {
err := testProvider.UpdateMemberRoleByMemberID(ctx, businessMemberID, "moderator")
assert.NoError(t, err)
// Verify role was updated
member, err := testProvider.GetMemberByMemberID(ctx, businessMemberID)
assert.NoError(t, err)
assert.Equal(t, "moderator", member["role_id"])
})
// Test UpdateMemberStatusByMemberID
t.Run("UpdateMemberStatusByMemberID", func(t *testing.T) {
err := testProvider.UpdateMemberStatusByMemberID(ctx, businessMemberID, "suspended")
assert.NoError(t, err)
// Verify status was updated
member, err := testProvider.GetMemberByMemberID(ctx, businessMemberID)
assert.NoError(t, err)
assert.Equal(t, "suspended", member["status"])
// Change back to active
err = testProvider.UpdateMemberStatusByMemberID(ctx, businessMemberID, "active")
assert.NoError(t, err)
})
// Test UpdateMemberLastActivityByMemberID
t.Run("UpdateMemberLastActivityByMemberID", func(t *testing.T) {
err := testProvider.UpdateMemberLastActivityByMemberID(ctx, businessMemberID)
assert.NoError(t, err)
// Verify last_active_at was updated and login_count incremented
member, err := testProvider.GetMemberByMemberID(ctx, businessMemberID)
assert.NoError(t, err)
assert.NotNil(t, member["last_active_at"])
// login_count should be at least 1
loginCount := member["login_count"]
if loginCount != nil {
switch v := loginCount.(type) {
case int:
assert.True(t, v >= 1, "login_count should be at least 1")
case int64:
assert.True(t, v >= 1, "login_count should be at least 1")
case int32:
assert.True(t, v >= 1, "login_count should be at least 1")
default:
t.Logf("Unexpected login_count type: %T, value: %v", loginCount, loginCount)
assert.True(t, false, "login_count should be a numeric type")
}
} else {
assert.True(t, false, "login_count should not be nil")
}
})
// Test RemoveMemberByMemberID (at the end)
t.Run("RemoveMemberByMemberID", func(t *testing.T) {
err := testProvider.RemoveMemberByMemberID(ctx, businessMemberID)
assert.NoError(t, err)
// Verify member was removed
_, err = testProvider.GetMemberByMemberID(ctx, businessMemberID)
assert.Error(t, err)
assert.Contains(t, err.Error(), "member not found")
})
}
func TestMemberExistsByTeamEmail(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 users
ownerUser := createTestUser(ctx, t, "owner"+testUUID)
// Create test team
teamMap := maps.MapStrAny{
"name": "Email Test Team " + testUUID,
"display_name": "Email Test " + testUUID,
"description": "A test team for email testing",
"owner_id": ownerUser,
"status": "active",
}
teamID, err := testProvider.CreateTeam(ctx, teamMap)
assert.NoError(t, err)
testEmail := "test" + testUUID + "@example.com"
// Create robot member with email
t.Run("CreateRobotMemberWithEmail", func(t *testing.T) {
robotData := maps.MapStrAny{
"display_name": "TestBot" + testUUID,
"email": testEmail,
"role_id": "bot",
}
businessMemberID, err := testProvider.CreateRobotMember(ctx, teamID, robotData)
assert.NoError(t, err)
assert.NotEmpty(t, businessMemberID)
})
// Test MemberExistsByTeamEmail
t.Run("MemberExistsByTeamEmail_Exists", func(t *testing.T) {
exists, err := testProvider.MemberExistsByTeamEmail(ctx, teamID, testEmail)
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")
assert.NoError(t, err)
assert.False(t, exists)
})
}
// Helper function createTestUser is defined in team_test.go

View file

@ -330,14 +330,14 @@ func TestTeamMemberOperations(t *testing.T) {
teamID, err := testProvider.CreateTeam(ctx, teamMap)
assert.NoError(t, err)
var memberID int64
var businessMemberID string
// Test AddMember (invitation-based)
t.Run("AddMember", func(t *testing.T) {
id, err := testProvider.AddMember(ctx, teamID, memberUser, "user", ownerUser)
memberID, err := testProvider.AddMember(ctx, teamID, memberUser, "user", ownerUser)
assert.NoError(t, err)
assert.Greater(t, id, int64(0))
memberID = id
assert.NotEmpty(t, memberID)
businessMemberID = memberID
})
// Test MemberExists
@ -362,9 +362,9 @@ func TestTeamMemberOperations(t *testing.T) {
assert.Equal(t, "pending", member["status"]) // Initially pending
})
// Test GetMemberByID
t.Run("GetMemberByID", func(t *testing.T) {
member, err := testProvider.GetMemberByID(ctx, memberID)
// Test GetMemberByMemberID
t.Run("GetMemberByMemberID", func(t *testing.T) {
member, err := testProvider.GetMemberByMemberID(ctx, businessMemberID)
assert.NoError(t, err)
assert.NotNil(t, member)
assert.Equal(t, teamID, member["team_id"])
@ -462,9 +462,9 @@ func TestTeamMemberOperations(t *testing.T) {
"robot_status": "idle",
}
robotID, err := testProvider.CreateRobotMember(ctx, teamID, robotData)
robotMemberID, err := testProvider.CreateRobotMember(ctx, teamID, robotData)
assert.NoError(t, err)
assert.Greater(t, robotID, int64(0))
assert.NotEmpty(t, robotMemberID)
})
// Test GetTeamRobotMembers

View file

@ -115,6 +115,79 @@ func (u *DefaultUser) generateInvitationID() (string, error) {
return prefix + id, nil
}
// generateMemberID generates a new member_id based on configured strategy (internal use)
func (u *DefaultUser) generateMemberID() (string, error) {
var id string
var err error
switch u.idStrategy {
case UUIDStrategy:
id, err = generateUUID()
case NanoIDStrategy:
id, err = generateNanoID(12) // 12 characters, URL-safe, readable
case NumericStrategy:
id, err = generateNumericID(12) // 12 characters, numeric, readable (default)
default:
id, err = generateNumericID(12) // 12 characters, URL-safe, readable
}
if err != nil {
return "", err
}
// Add prefix if configured
if u.idPrefix != "" {
return u.idPrefix + id, nil
}
return id, nil
}
// generateMemberIDWithRetry generates a unique member_id with collision detection
func (u *DefaultUser) generateMemberIDWithRetry(ctx context.Context) (string, error) {
const maxRetries = 10 // Prevent infinite loops
for i := 0; i < maxRetries; i++ {
// Generate new ID
id, err := u.generateMemberID()
if err != nil {
return "", fmt.Errorf("failed to generate member_id: %w", err)
}
// Check if ID already exists
exists, err := u.memberIDExists(ctx, id)
if err != nil {
return "", fmt.Errorf("failed to check member_id existence: %w", err)
}
if !exists {
return id, nil // Found unique ID
}
// ID exists, retry with new generation
}
return "", fmt.Errorf("failed to generate unique member_id after %d retries", maxRetries)
}
// memberIDExists checks if a member_id already exists in the database
func (u *DefaultUser) memberIDExists(ctx context.Context, memberID string) (bool, error) {
m := model.Select(u.memberModel)
members, err := m.Get(model.QueryParam{
Select: []interface{}{"id"}, // Just get primary key, minimal data
Wheres: []model.QueryWhere{
{Column: "member_id", Value: memberID},
},
Limit: 1,
})
if err != nil {
return false, err
}
return len(members) > 0, nil
}
// userIDExists checks if a user_id already exists in the database
func (u *DefaultUser) userIDExists(ctx context.Context, userID string) (bool, error) {
m := model.Select(u.model)

View file

@ -296,22 +296,27 @@ type UserProvider interface {
GetMember(ctx context.Context, teamID string, userID string) (maps.MapStrAny, error)
GetMemberDetail(ctx context.Context, teamID string, userID string) (maps.MapStrAny, error)
GetMemberByID(ctx context.Context, memberID int64) (maps.MapStrAny, error)
GetMemberByMemberID(ctx context.Context, memberID string) (maps.MapStrAny, error)
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)
CreateMember(ctx context.Context, memberData maps.MapStrAny) (int64, error)
MemberExistsByTeamEmail(ctx context.Context, teamID string, email 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
UpdateMemberByMemberID(ctx context.Context, memberID string, memberData maps.MapStrAny) error
UpdateMemberByInvitationID(ctx context.Context, invitationID string, memberData maps.MapStrAny) error
RemoveMember(ctx context.Context, teamID string, userID string) error
RemoveMemberByMemberID(ctx context.Context, memberID string) error
RemoveMemberByInvitationID(ctx context.Context, invitationID string) error
RemoveAllTeamMembers(ctx context.Context, teamID string) error
// Member Invitation Management
AddMember(ctx context.Context, teamID string, userID string, roleID string, invitedBy string) (int64, error)
AddMember(ctx context.Context, teamID string, userID string, roleID string, invitedBy string) (string, error)
AcceptInvitation(ctx context.Context, invitationID string, invitationToken string, userID string) error
// Robot Member Operations
CreateRobotMember(ctx context.Context, teamID string, robotData maps.MapStrAny) (int64, error)
CreateRobotMember(ctx context.Context, teamID string, robotData maps.MapStrAny) (string, error)
UpdateRobotActivity(ctx context.Context, memberID int64, robotStatus string) error
GetActiveRobotMembers(ctx context.Context) ([]maps.MapStr, error)
@ -323,8 +328,11 @@ type UserProvider interface {
// Member Management
UpdateMemberRole(ctx context.Context, teamID string, userID string, roleID string) error
UpdateMemberRoleByMemberID(ctx context.Context, memberID string, roleID string) error
UpdateMemberStatus(ctx context.Context, teamID string, userID string, status string) error
UpdateMemberStatusByMemberID(ctx context.Context, memberID string, status string) error
UpdateMemberLastActivity(ctx context.Context, teamID string, userID string) error
UpdateMemberLastActivityByMemberID(ctx context.Context, memberID string) error
// Member List and Search
PaginateMembers(ctx context.Context, param model.QueryParam, page int, pagesize int) (maps.MapStr, error)

View file

@ -463,7 +463,7 @@ func setupACLTestData(t *testing.T, ctx context.Context, provider types.UserProv
data := &ACLTestData{
UserIDs: make([]string, 0),
TeamIDs: make([]string, 0),
MemberIDs: make([]int64, 0),
MemberIDs: make([]string, 0),
}
// Set up roles first
@ -577,7 +577,7 @@ func cleanupACLTestData(t *testing.T, ctx context.Context, provider types.UserPr
type ACLTestData struct {
UserIDs []string
TeamIDs []string
MemberIDs []int64
MemberIDs []string
}
// TestEnforceIntegration tests the complete enforcement flow

View file

@ -1333,4 +1333,162 @@ func toString(v interface{}) string {
}
}
// TestMemberCheckEmail tests the GET /user/teams/:team_id/members/check endpoint
func TestMemberCheckEmail(t *testing.T) {
// Initialize test environment
serverURL := testutils.Prepare(t)
defer testutils.Clean()
// Get base URL from server config
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
// Register a test client for OAuth authentication
testClient := testutils.RegisterTestClient(t, "Member Check Email Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, testClient.ClientID)
// Obtain access token for authenticated requests
tokenInfo := testutils.ObtainAccessToken(t, serverURL, testClient.ClientID, testClient.ClientSecret, "https://localhost/callback", "openid profile")
// Use UUID to ensure unique test data
testUUID := strings.ReplaceAll(uuid.New().String(), "-", "")[:8]
// Create a test team
createdTeam := createTestTeam(t, serverURL, baseURL, tokenInfo.AccessToken, "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)
robotBody := map[string]interface{}{
"name": "Existing Robot",
"email": existingEmail,
"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))
robotReq.Header.Set("Content-Type", "application/json")
robotReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
client := &http.Client{}
robotResp, err := client.Do(robotReq)
assert.NoError(t, err)
if robotResp != nil {
robotResp.Body.Close()
assert.Equal(t, 201, robotResp.StatusCode, "Should create robot member successfully")
}
testCases := []struct {
name string
teamID string
email string
headers map[string]string
expectCode int
expectExists bool
expectMsg string
}{
{
"check email without authentication",
teamID,
existingEmail,
map[string]string{},
401,
false,
"should require authentication",
},
{
"check existing email",
teamID,
existingEmail,
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
},
200,
true,
"should return exists=true for existing email",
},
{
"check non-existing email",
teamID,
fmt.Sprintf("nonexistent-%s@test.com", testUUID),
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
},
200,
false,
"should return exists=false for non-existing email",
},
{
"check email without email parameter",
teamID,
"",
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
},
400,
false,
"should require email parameter",
},
{
"check email in non-existent team",
"non-existent-team-id",
"test@example.com",
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
},
404,
false,
"should return not found for non-existent team",
},
}
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
}
req, err := http.NewRequest("GET", requestURL, nil)
assert.NoError(t, err, "Should create HTTP request")
// Add headers
for key, value := range tc.headers {
req.Header.Set(key, value)
}
resp, err := client.Do(req)
assert.NoError(t, err, "HTTP request should succeed")
if resp != nil {
defer resp.Body.Close()
assert.Equal(t, tc.expectCode, resp.StatusCode, "Expected status code %d for %s", tc.expectCode, tc.name)
body, err := io.ReadAll(resp.Body)
assert.NoError(t, err, "Should read response body")
if resp.StatusCode == 200 {
// Parse response
var response map[string]interface{}
err = json.Unmarshal(body, &response)
assert.NoError(t, err, "Should parse JSON response")
// Verify response structure
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")
// 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")
}
t.Logf("Member check email test %s: status=%d, body=%s", tc.name, resp.StatusCode, string(body))
}
})
}
}
// Note: getTeamID function is already defined in team_test.go

View file

@ -91,6 +91,75 @@ 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) {
// Get authorized user info
authInfo := oauth.GetAuthorizedInfo(c)
if authInfo == nil || authInfo.UserID == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidClient.Code,
ErrorDescription: "User not authenticated",
}
response.RespondWithError(c, response.StatusUnauthorized, errorResp)
return
}
teamID := c.Param("id")
if teamID == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Team ID is required",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
email := c.Query("email")
if email == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "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)
if err != nil {
log.Error("Failed to check member email: %v", err)
// Check error type for appropriate response
if strings.Contains(err.Error(), "not found") {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Team not found",
}
response.RespondWithError(c, response.StatusNotFound, errorResp)
} else if strings.Contains(err.Error(), "access denied") {
errorResp := &response.ErrorResponse{
Code: response.ErrAccessDenied.Code,
ErrorDescription: err.Error(),
}
response.RespondWithError(c, response.StatusForbidden, errorResp)
} else {
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: fmt.Sprintf("Failed to check member email: %v", err),
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
}
return
}
// Return result
result := map[string]interface{}{
"exists": exists,
"email": email,
"team_id": teamID,
}
response.RespondWithSuccess(c, http.StatusOK, result)
}
// GinMemberGet handles GET /teams/:team_id/members/:member_id - Get team member details
func GinMemberGet(c *gin.Context) {
// Get authorized user info
@ -620,29 +689,57 @@ func memberGet(ctx context.Context, userID, teamID, memberID string) (maps.MapSt
return memberData, nil
}
// memberCreateRobot handles the business logic for creating a robot member
func memberCreateRobot(ctx context.Context, userID, teamID string, robotData maps.MapStrAny) (int64, error) {
// Check if user has access to the team (write permission: owner only)
isOwner, _, err := checkTeamAccess(ctx, teamID, userID)
// 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) {
// Check if user has access to the team (read permission: owner or member)
isOwner, isMember, err := checkTeamAccess(ctx, teamID, userID)
if err != nil {
return 0, err
return false, err
}
// Only allow access if user is owner
if !isOwner {
return 0, fmt.Errorf("access denied: only team owner can add robot members")
// Allow access if user is owner or member
if !isOwner && !isMember {
return false, fmt.Errorf("access denied: user is not a member of this team")
}
// Get user provider instance
provider, err := getUserProvider()
if err != nil {
return 0, fmt.Errorf("failed to get user provider: %w", err)
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)
if err != nil {
return false, fmt.Errorf("failed to check member existence: %w", err)
}
return exists, nil
}
// memberCreateRobot handles the business logic for creating a robot member
func memberCreateRobot(ctx context.Context, userID, teamID string, robotData maps.MapStrAny) (string, error) {
// Check if user has access to the team (write permission: owner only)
isOwner, _, err := checkTeamAccess(ctx, teamID, userID)
if err != nil {
return "", err
}
// Only allow access if user is owner
if !isOwner {
return "", fmt.Errorf("access denied: only team owner can add robot members")
}
// Get user provider instance
provider, err := getUserProvider()
if err != nil {
return "", fmt.Errorf("failed to get user provider: %w", err)
}
// Use CreateRobotMember method which handles robot-specific logic
memberID, err := provider.CreateRobotMember(ctx, teamID, robotData)
if err != nil {
return 0, fmt.Errorf("failed to create robot member: %w", err)
return "", fmt.Errorf("failed to create robot member: %w", err)
}
return memberID, nil

View file

@ -1162,13 +1162,13 @@ func teamInvitationCreate(ctx context.Context, userID, teamID string, invitation
invitationData["updated_at"] = time.Now()
// Create invitation (as a pending member)
memberID, err := provider.CreateMember(ctx, invitationData)
businessMemberID, err := provider.CreateMember(ctx, invitationData)
if err != nil {
return "", fmt.Errorf("failed to create invitation: %w", err)
}
// Get the created member to retrieve the generated invitation_id
createdMember, err := provider.GetMemberByID(ctx, memberID)
createdMember, err := provider.GetMemberByMemberID(ctx, businessMemberID)
if err != nil {
return "", fmt.Errorf("failed to retrieve created invitation: %w", err)
}

View file

@ -133,11 +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 /teams/:id/members - List team members
team.POST("/:id/members/robots", GinMemberCreateRobot) // POST /teams/:id/members/robots - Add robot member
team.GET("/:id/members/:member_id", GinMemberGet) // GET /teams/:id/members/:member_id - Get member details
team.PUT("/:id/members/:member_id", GinMemberUpdate) // PUT /teams/:id/members/:member_id - Update member
team.DELETE("/:id/members/:member_id", GinMemberDelete) // DELETE /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", 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 Invitations - Nested resource endpoints
team.GET("/:id/invitations", GinTeamInvitationList) // GET /teams/:id/invitations - List invitations

View file

@ -18,6 +18,15 @@
"comment": "Primary key identifier",
"primary": true
},
{
"name": "member_id",
"type": "string",
"label": "Member ID",
"comment": "Global unique member identifier",
"length": 255,
"unique": true,
"index": true
},
{
"name": "team_id",
"type": "string",