Merge pull request #1160 from trheyi/main

Add team ownership and membership checks in user management
This commit is contained in:
Max 2025-09-23 17:48:03 +08:00 committed by GitHub
commit 5bbdfa3286
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 4232 additions and 172 deletions

File diff suppressed because one or more lines are too long

View file

@ -145,8 +145,8 @@ var (
// DefaultMemberFields contains basic member fields
DefaultMemberFields = []interface{}{
"id", "team_id", "user_id", "member_type", "role_id", "status",
"invited_by", "invited_at", "joined_at", "last_active_at", "login_count",
"created_at", "updated_at",
"invitation_id", "invited_by", "invited_at", "joined_at", "invitation_token", "invitation_expires_at",
"last_active_at", "login_count", "message", "created_at", "updated_at",
}
// DefaultMemberDetailFields contains all member fields including robot config and permissions
@ -155,7 +155,7 @@ var (
"robot_name", "robot_description", "robot_avatar", "robot_config", "agents", "tools",
"mcp_servers", "data_access_permissions", "system_prompt", "is_active_robot",
"schedule_config", "random_activity", "activity_frequency", "last_robot_activity",
"robot_status", "invited_by", "invited_at", "joined_at", "invitation_token",
"robot_status", "invitation_id", "invited_by", "invited_at", "joined_at", "invitation_token",
"invitation_expires_at", "permissions", "restrictions", "last_active_at",
"login_count", "notes", "metadata", "created_at", "updated_at",
}

View file

@ -79,6 +79,28 @@ func (u *DefaultUser) GetMemberByID(ctx context.Context, memberID int64) (maps.M
return members[0], nil
}
// GetMemberByInvitationID retrieves member information by invitation_id
func (u *DefaultUser) GetMemberByInvitationID(ctx context.Context, invitationID string) (maps.MapStrAny, error) {
m := model.Select(u.memberModel)
members, err := m.Get(model.QueryParam{
Select: u.memberFields,
Wheres: []model.QueryWhere{
{Column: "invitation_id", Value: invitationID},
},
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)
@ -116,14 +138,25 @@ func (u *DefaultUser) CreateMember(ctx context.Context, memberData maps.MapStrAn
memberData["status"] = "pending"
}
// For user members, user_id is required
// For user members, user_id is required unless it's an invitation (status=pending)
memberType := memberData["member_type"].(string)
if memberType == "user" {
status, _ := memberData["status"].(string)
if memberType == "user" && status != "pending" {
if _, exists := memberData["user_id"]; !exists {
return 0, fmt.Errorf("user_id is required for user members")
return 0, fmt.Errorf("user_id is required for active user members")
}
}
// Generate invitation_id for pending invitations
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)
}
memberData["invitation_id"] = invitationID
}
m := model.Select(u.memberModel)
id, err := m.Create(memberData)
if err != nil {
@ -525,6 +558,60 @@ func (u *DefaultUser) UpdateRobotActivity(ctx context.Context, memberID int64, r
return u.UpdateMemberByID(ctx, memberID, updateData)
}
// UpdateMemberByInvitationID updates a member by invitation_id
func (u *DefaultUser) UpdateMemberByInvitationID(ctx context.Context, invitationID string, memberData maps.MapStrAny) error {
// Remove sensitive fields that should not be updated directly
// Note: user_id is allowed for invitation acceptance (pending -> active transition)
sensitiveFields := []string{"id", "team_id", "created_at", "invitation_id"}
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: "invitation_id", Value: invitationID},
},
Limit: 1,
}, memberData)
if err != nil {
return fmt.Errorf(ErrFailedToUpdateMember, err)
}
if affected == 0 {
return fmt.Errorf(ErrMemberNotFound)
}
return nil
}
// RemoveMemberByInvitationID removes a member by invitation_id
func (u *DefaultUser) RemoveMemberByInvitationID(ctx context.Context, invitationID string) error {
m := model.Select(u.memberModel)
affected, err := m.DeleteWhere(model.QueryParam{
Wheres: []model.QueryWhere{
{Column: "invitation_id", Value: invitationID},
},
Limit: 1,
})
if err != nil {
return fmt.Errorf(ErrFailedToDeleteMember, err)
}
if affected == 0 {
return fmt.Errorf(ErrMemberNotFound)
}
return nil
}
// PaginateMembers retrieves paginated list of members
func (u *DefaultUser) PaginateMembers(ctx context.Context, param model.QueryParam, page int, pagesize int) (maps.MapStr, error) {
// Set default select fields if not provided

View file

@ -648,15 +648,16 @@ func TestMemberErrorHandling(t *testing.T) {
assert.Error(t, err)
assert.Contains(t, err.Error(), "role_id is required")
// Missing user_id for user member
// Missing user_id for active user member
memberData = maps.MapStrAny{
"team_id": "test-team",
"role_id": "user",
"member_type": "user",
"status": "active", // Explicitly set to active to trigger validation
}
_, err = testProvider.CreateMember(ctx, memberData)
assert.Error(t, err)
assert.Contains(t, err.Error(), "user_id is required for user members")
assert.Contains(t, err.Error(), "user_id is required for active user members")
})
t.Run("UpdateMember_EmptyData", func(t *testing.T) {
@ -746,4 +747,249 @@ func TestMemberInvitationExpiry(t *testing.T) {
})
}
func TestMemberInvitationIDOperations(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)
inviteeUser := createTestUser(ctx, t, "invitee"+testUUID)
// Create test team
teamMap := maps.MapStrAny{
"name": "Invitation ID Test Team " + testUUID,
"display_name": "Invitation ID Test " + testUUID,
"description": "A test team for invitation_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 invitationID string
// Test CreateMember with pending status (should generate invitation_id)
t.Run("CreateMember_GeneratesInvitationID", func(t *testing.T) {
memberData := maps.MapStrAny{
"team_id": teamID,
"user_id": nil, // Simulate invitation to unregistered user
"member_type": "user",
"role_id": "user",
"status": "pending",
"invited_by": ownerUser,
}
memberID, err := testProvider.CreateMember(ctx, memberData)
assert.NoError(t, err)
assert.Greater(t, memberID, int64(0))
// Get the created member to verify invitation_id was generated
member, err := testProvider.GetMemberByID(ctx, memberID)
assert.NoError(t, err)
assert.NotNil(t, member["invitation_id"])
assert.NotEmpty(t, member["invitation_id"])
invitationID = member["invitation_id"].(string)
t.Logf("Generated invitation_id: %s", invitationID)
assert.True(t, strings.Contains(invitationID, "inv_"), "invitation_id should contain inv_ prefix, got: "+invitationID)
})
// Test GetMemberByInvitationID
t.Run("GetMemberByInvitationID", func(t *testing.T) {
member, err := testProvider.GetMemberByInvitationID(ctx, invitationID)
assert.NoError(t, err)
assert.NotNil(t, member)
assert.Equal(t, invitationID, member["invitation_id"])
assert.Equal(t, teamID, member["team_id"])
assert.Equal(t, "pending", member["status"])
assert.Equal(t, ownerUser, member["invited_by"])
})
// Test GetMemberByInvitationID with non-existent invitation
t.Run("GetMemberByInvitationID_NotFound", func(t *testing.T) {
_, err := testProvider.GetMemberByInvitationID(ctx, "non-existent-invitation-id")
assert.Error(t, err)
assert.Contains(t, err.Error(), "member not found")
})
// Test UpdateMemberByInvitationID
t.Run("UpdateMemberByInvitationID", func(t *testing.T) {
updateData := maps.MapStrAny{
"user_id": inviteeUser, // Now associate with a user
"status": "active",
"joined_at": time.Now(),
"notes": "Invitation accepted",
}
err := testProvider.UpdateMemberByInvitationID(ctx, invitationID, updateData)
assert.NoError(t, err)
// Verify update
member, err := testProvider.GetMemberByInvitationID(ctx, invitationID)
assert.NoError(t, err)
assert.Equal(t, inviteeUser, member["user_id"])
assert.Equal(t, "active", member["status"])
assert.NotNil(t, member["joined_at"])
// Test updating sensitive fields (should be ignored except user_id which is allowed)
sensitiveData := maps.MapStrAny{
"id": 999,
"team_id": "new-team",
"invitation_id": "new-invitation-id",
}
err = testProvider.UpdateMemberByInvitationID(ctx, invitationID, sensitiveData)
assert.NoError(t, err) // Should not error, just ignore sensitive fields
// Verify sensitive fields were not updated
member, err = testProvider.GetMemberByInvitationID(ctx, invitationID)
assert.NoError(t, err)
assert.Equal(t, invitationID, member["invitation_id"]) // Should remain unchanged
assert.Equal(t, teamID, member["team_id"]) // Should remain unchanged
assert.Equal(t, inviteeUser, member["user_id"]) // Should remain as updated value
})
// Test UpdateMemberByInvitationID with non-existent invitation
t.Run("UpdateMemberByInvitationID_NotFound", func(t *testing.T) {
updateData := maps.MapStrAny{"notes": "test"}
err := testProvider.UpdateMemberByInvitationID(ctx, "non-existent-invitation-id", updateData)
assert.Error(t, err)
assert.Contains(t, err.Error(), "member not found")
})
// Test UpdateMemberByInvitationID with empty data (should not error)
t.Run("UpdateMemberByInvitationID_EmptyData", func(t *testing.T) {
err := testProvider.UpdateMemberByInvitationID(ctx, invitationID, maps.MapStrAny{})
assert.NoError(t, err) // Should not error, just do nothing
})
// Test RemoveMemberByInvitationID (at the end)
t.Run("RemoveMemberByInvitationID", func(t *testing.T) {
err := testProvider.RemoveMemberByInvitationID(ctx, invitationID)
assert.NoError(t, err)
// Verify member was removed
_, err = testProvider.GetMemberByInvitationID(ctx, invitationID)
assert.Error(t, err)
assert.Contains(t, err.Error(), "member not found")
})
// Test RemoveMemberByInvitationID with non-existent invitation
t.Run("RemoveMemberByInvitationID_NotFound", func(t *testing.T) {
err := testProvider.RemoveMemberByInvitationID(ctx, "non-existent-invitation-id")
assert.Error(t, err)
assert.Contains(t, err.Error(), "member not found")
})
}
func TestCreateMemberInvitationIDGeneration(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
ownerUser := createTestUser(ctx, t, "owner"+testUUID)
// Create test team
teamMap := maps.MapStrAny{
"name": "ID Generation Test Team " + testUUID,
"display_name": "ID Generation Test " + testUUID,
"description": "A test team for invitation_id generation testing",
"owner_id": ownerUser,
"status": "active",
}
teamID, err := testProvider.CreateTeam(ctx, teamMap)
assert.NoError(t, err)
// Test invitation_id generation for pending members
t.Run("CreateMember_PendingStatus_GeneratesInvitationID", func(t *testing.T) {
memberData := maps.MapStrAny{
"team_id": teamID,
"user_id": nil, // No user_id for pending invitation
"member_type": "user",
"role_id": "user",
"status": "pending",
"invited_by": ownerUser,
}
memberID, err := testProvider.CreateMember(ctx, memberData)
assert.NoError(t, err)
// Get the created member
member, err := testProvider.GetMemberByID(ctx, memberID)
assert.NoError(t, err)
// Verify invitation_id was generated
assert.NotNil(t, member["invitation_id"])
assert.NotEmpty(t, member["invitation_id"])
invitationID := member["invitation_id"].(string)
t.Logf("Generated invitation_id: %s", invitationID)
assert.True(t, strings.Contains(invitationID, "inv_"), "invitation_id should contain inv_ prefix, got: "+invitationID)
assert.True(t, len(invitationID) > 4, "invitation_id should be longer than just the prefix")
})
// Test that active members don't get invitation_id
t.Run("CreateMember_ActiveStatus_NoInvitationID", func(t *testing.T) {
activeUser := createTestUser(ctx, t, "active"+testUUID)
memberData := maps.MapStrAny{
"team_id": teamID,
"user_id": activeUser,
"member_type": "user",
"role_id": "user",
"status": "active",
}
memberID, err := testProvider.CreateMember(ctx, memberData)
assert.NoError(t, err)
// Get the created member
member, err := testProvider.GetMemberByID(ctx, memberID)
assert.NoError(t, err)
// Verify invitation_id is nil for active members
assert.Nil(t, member["invitation_id"])
})
// Test explicit invitation_id is preserved
t.Run("CreateMember_ExplicitInvitationID_Preserved", func(t *testing.T) {
explicitInvitationID := "inv_explicit_test_" + testUUID
memberData := maps.MapStrAny{
"team_id": teamID,
"user_id": nil,
"member_type": "user",
"role_id": "user",
"status": "pending",
"invited_by": ownerUser,
"invitation_id": explicitInvitationID,
}
memberID, err := testProvider.CreateMember(ctx, memberData)
assert.NoError(t, err)
// Get the created member
member, err := testProvider.GetMemberByID(ctx, memberID)
assert.NoError(t, err)
// Verify explicit invitation_id was preserved
assert.Equal(t, explicitInvitationID, member["invitation_id"])
})
}
// Helper function createTestUser is defined in team_test.go

View file

@ -348,3 +348,51 @@ func (u *DefaultUser) TransferTeamOwnership(ctx context.Context, teamID string,
return u.UpdateTeam(ctx, teamID, updateData)
}
// IsTeamOwner checks if a user is the owner of a team
func (u *DefaultUser) IsTeamOwner(ctx context.Context, teamID string, userID string) (bool, error) {
teamData, err := u.GetTeam(ctx, teamID)
if err != nil {
return false, fmt.Errorf("failed to get team: %w", err)
}
ownerID, ok := teamData["owner_id"].(string)
if !ok {
return false, fmt.Errorf("invalid owner_id type in team data")
}
return ownerID == userID, nil
}
// IsTeamMember checks if a user is a member of a team (includes owner)
func (u *DefaultUser) IsTeamMember(ctx context.Context, teamID string, userID string) (bool, error) {
// First check if user is the owner
isOwner, err := u.IsTeamOwner(ctx, teamID, userID)
if err != nil {
return false, err
}
if isOwner {
return true, nil
}
// Then check if user is a member
return u.MemberExists(ctx, teamID, userID)
}
// CheckTeamAccess checks user's access level to a team
// Returns: (isOwner bool, isMember bool, error)
func (u *DefaultUser) CheckTeamAccess(ctx context.Context, teamID string, userID string) (bool, bool, error) {
// Check if user is the owner
isOwner, err := u.IsTeamOwner(ctx, teamID, userID)
if err != nil {
return false, false, err
}
// Check if user is a member (this will return true for owner as well, but we already know that)
isMember, err := u.IsTeamMember(ctx, teamID, userID)
if err != nil {
return false, false, err
}
return isOwner, isMember, nil
}

View file

@ -86,6 +86,35 @@ func (u *DefaultUser) generateUserID() (string, error) {
return id, nil
}
// generateInvitationID generates a new invitation_id based on configured strategy (internal use)
func (u *DefaultUser) generateInvitationID() (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 (could be different from user prefix)
prefix := "inv_" // Default invitation prefix
if u.idPrefix != "" {
prefix = u.idPrefix + "inv_"
}
return prefix + id, 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

@ -274,6 +274,11 @@ type UserProvider interface {
UnverifyTeam(ctx context.Context, teamID string) error
TransferTeamOwnership(ctx context.Context, teamID string, newOwnerID string) error
// Team Permission Checks
IsTeamOwner(ctx context.Context, teamID string, userID string) (bool, error)
IsTeamMember(ctx context.Context, teamID string, userID string) (bool, error)
CheckTeamAccess(ctx context.Context, teamID string, userID string) (isOwner bool, isMember bool, err error)
// ============================================================================
// Member Resource
// ============================================================================
@ -282,11 +287,15 @@ 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)
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)
UpdateMember(ctx context.Context, teamID string, userID string, memberData maps.MapStrAny) error
UpdateMemberByID(ctx context.Context, memberID int64, memberData maps.MapStrAny) error
UpdateMemberByInvitationID(ctx context.Context, invitationID string, memberData maps.MapStrAny) error
RemoveMember(ctx context.Context, teamID string, userID 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)

View file

@ -0,0 +1,777 @@
package user_test
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/openapi"
"github.com/yaoapp/yao/openapi/tests/testutils"
"github.com/yaoapp/yao/openapi/user"
)
// TestInvitationCreate tests the POST /user/teams/:team_id/invitations endpoint
func TestInvitationCreate(t *testing.T) {
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 test client
client := testutils.RegisterTestClient(t, "Invitation Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
// Get access token
tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
// Use UUID to ensure unique identifiers
testUUID := strings.ReplaceAll(uuid.New().String(), "-", "")[:8]
// Create test team first
createdTeam := createTestTeam(t, serverURL, baseURL, tokenInfo.AccessToken, "Invitation Test Team "+testUUID)
teamID := getTeamID(createdTeam)
// Test successful invitation creation
t.Run("CreateInvitation_Success", func(t *testing.T) {
invitationData := map[string]interface{}{
"user_id": nil, // Invite unregistered user
"member_type": "user",
"role_id": "user",
"message": "Welcome to our team!",
"settings": map[string]interface{}{
"send_email": true,
},
}
jsonData, _ := json.Marshal(invitationData)
url := fmt.Sprintf("%s%s/user/teams/%s/invitations", serverURL, baseURL, teamID)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
assert.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusCreated, resp.StatusCode)
var result map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&result)
assert.NoError(t, err)
// Should return invitation_id
assert.Contains(t, result, "invitation_id")
assert.NotEmpty(t, result["invitation_id"])
invitationID := result["invitation_id"].(string)
assert.True(t, strings.HasPrefix(invitationID, "inv_"), "invitation_id should have inv_ prefix")
})
// Test invitation creation with registered user
t.Run("CreateInvitation_RegisteredUser", func(t *testing.T) {
// Create another user to invite
anotherTokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
invitationData := map[string]interface{}{
"user_id": anotherTokenInfo.UserID,
"member_type": "user",
"role_id": "admin",
"message": "Join as admin!",
}
jsonData, _ := json.Marshal(invitationData)
url := fmt.Sprintf("%s%s/user/teams/%s/invitations", serverURL, baseURL, teamID)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
assert.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusCreated, resp.StatusCode)
var result map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&result)
assert.NoError(t, err)
assert.Contains(t, result, "invitation_id")
assert.NotEmpty(t, result["invitation_id"])
})
// Test missing required fields
t.Run("CreateInvitation_MissingRoleID", func(t *testing.T) {
invitationData := map[string]interface{}{
"user_id": nil,
"member_type": "user",
// Missing role_id
}
jsonData, _ := json.Marshal(invitationData)
url := fmt.Sprintf("%s%s/user/teams/%s/invitations", serverURL, baseURL, teamID)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
assert.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
// Test non-existent team
t.Run("CreateInvitation_NonExistentTeam", func(t *testing.T) {
invitationData := map[string]interface{}{
"user_id": nil,
"member_type": "user",
"role_id": "user",
}
jsonData, _ := json.Marshal(invitationData)
url := fmt.Sprintf("%s%s/user/teams/non-existent-team/invitations", serverURL, baseURL)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
assert.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
})
// Test unauthorized access
t.Run("CreateInvitation_Unauthorized", func(t *testing.T) {
invitationData := map[string]interface{}{
"user_id": nil,
"member_type": "user",
"role_id": "user",
}
jsonData, _ := json.Marshal(invitationData)
url := fmt.Sprintf("%s%s/user/teams/%s/invitations", serverURL, baseURL, teamID)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
// No Authorization header
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
assert.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
}
// TestInvitationList tests the GET /user/teams/:team_id/invitations endpoint
func TestInvitationList(t *testing.T) {
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 test client
client := testutils.RegisterTestClient(t, "Invitation List Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
// Get access token
tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
// Use UUID to ensure unique identifiers
testUUID := strings.ReplaceAll(uuid.New().String(), "-", "")[:8]
// Create test team
createdTeam := createTestTeam(t, serverURL, baseURL, tokenInfo.AccessToken, "Invitation List Test Team "+testUUID)
teamID := getTeamID(createdTeam)
// Create some test invitations
invitationIDs := make([]string, 0)
for i := 0; i < 3; i++ {
invitationID := createTestInvitationWithMessage(t, serverURL, baseURL, tokenInfo.AccessToken, teamID, "", fmt.Sprintf("Test invitation %d", i+1))
invitationIDs = append(invitationIDs, invitationID)
}
// Test successful list
t.Run("ListInvitations_Success", func(t *testing.T) {
url := fmt.Sprintf("%s%s/user/teams/%s/invitations", serverURL, baseURL, teamID)
req, err := http.NewRequest("GET", url, nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
assert.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&result)
assert.NoError(t, err)
// Should contain pagination info
assert.Contains(t, result, "data")
assert.Contains(t, result, "total")
data := result["data"].([]interface{})
assert.True(t, len(data) >= 3) // At least our 3 test invitations
})
// Test with pagination
t.Run("ListInvitations_Pagination", func(t *testing.T) {
url := fmt.Sprintf("%s%s/user/teams/%s/invitations?page=1&pagesize=2", serverURL, baseURL, teamID)
req, err := http.NewRequest("GET", url, nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
assert.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&result)
assert.NoError(t, err)
data := result["data"].([]interface{})
assert.True(t, len(data) <= 2) // Should respect pagesize
})
// Test status filter
t.Run("ListInvitations_StatusFilter", func(t *testing.T) {
url := fmt.Sprintf("%s%s/user/teams/%s/invitations?status=pending", serverURL, baseURL, teamID)
req, err := http.NewRequest("GET", url, nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
assert.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&result)
assert.NoError(t, err)
data := result["data"].([]interface{})
// All returned invitations should be pending
for _, item := range data {
invitation := item.(map[string]interface{})
assert.Equal(t, "pending", invitation["status"])
}
})
// Test unauthorized access
t.Run("ListInvitations_Unauthorized", func(t *testing.T) {
url := fmt.Sprintf("%s%s/user/teams/%s/invitations", serverURL, baseURL, teamID)
req, err := http.NewRequest("GET", url, nil)
assert.NoError(t, err)
// No Authorization header
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
assert.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
// Test non-existent team
t.Run("ListInvitations_NonExistentTeam", func(t *testing.T) {
url := fmt.Sprintf("%s%s/user/teams/non-existent-team/invitations", serverURL, baseURL)
req, err := http.NewRequest("GET", url, nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
assert.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
})
}
// TestInvitationGet tests the GET /user/teams/:team_id/invitations/:invitation_id endpoint
func TestInvitationGet(t *testing.T) {
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 test client
client := testutils.RegisterTestClient(t, "Invitation Get Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
// Get access token
tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
// Use UUID to ensure unique identifiers
testUUID := strings.ReplaceAll(uuid.New().String(), "-", "")[:8]
// Create test team
createdTeam := createTestTeam(t, serverURL, baseURL, tokenInfo.AccessToken, "Invitation Get Test Team "+testUUID)
teamID := getTeamID(createdTeam)
// Create test invitation
invitationID := createTestInvitation(t, serverURL, baseURL, tokenInfo.AccessToken, teamID, "") // Unregistered user
// Test successful get
t.Run("GetInvitation_Success", func(t *testing.T) {
url := fmt.Sprintf("%s%s/user/teams/%s/invitations/%s", serverURL, baseURL, teamID, invitationID)
req, err := http.NewRequest("GET", url, nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
assert.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result user.InvitationDetailResponse
err = json.NewDecoder(resp.Body).Decode(&result)
assert.NoError(t, err)
assert.Equal(t, teamID, result.TeamID)
assert.Equal(t, "pending", result.Status)
assert.NotEmpty(t, result.InvitationToken)
assert.NotEmpty(t, result.InvitedAt)
})
// Test non-existent invitation
t.Run("GetInvitation_NotFound", func(t *testing.T) {
url := fmt.Sprintf("%s%s/user/teams/%s/invitations/non-existent-invitation", serverURL, baseURL, teamID)
req, err := http.NewRequest("GET", url, nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
assert.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
})
// Test unauthorized access
t.Run("GetInvitation_Unauthorized", func(t *testing.T) {
url := fmt.Sprintf("%s%s/user/teams/%s/invitations/%s", serverURL, baseURL, teamID, invitationID)
req, err := http.NewRequest("GET", url, nil)
assert.NoError(t, err)
// No Authorization header
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
assert.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
// Test wrong team
t.Run("GetInvitation_WrongTeam", func(t *testing.T) {
// Create another team
anotherCreatedTeam := createTestTeam(t, serverURL, baseURL, tokenInfo.AccessToken, "Another Team "+testUUID)
anotherTeamID := getTeamID(anotherCreatedTeam)
url := fmt.Sprintf("%s%s/user/teams/%s/invitations/%s", serverURL, baseURL, anotherTeamID, invitationID)
req, err := http.NewRequest("GET", url, nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
assert.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
})
}
// TestInvitationResend tests the PUT /user/teams/:team_id/invitations/:invitation_id/resend endpoint
func TestInvitationResend(t *testing.T) {
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 test client
client := testutils.RegisterTestClient(t, "Invitation Resend Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
// Get access token
tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
// Use UUID to ensure unique identifiers
testUUID := strings.ReplaceAll(uuid.New().String(), "-", "")[:8]
// Create test team
createdTeam := createTestTeam(t, serverURL, baseURL, tokenInfo.AccessToken, "Invitation Resend Test Team "+testUUID)
teamID := getTeamID(createdTeam)
// Create test invitation
invitationID := createTestInvitation(t, serverURL, baseURL, tokenInfo.AccessToken, teamID, "") // Unregistered user
// Test successful resend
t.Run("ResendInvitation_Success", func(t *testing.T) {
url := fmt.Sprintf("%s%s/user/teams/%s/invitations/%s/resend", serverURL, baseURL, teamID, invitationID)
req, err := http.NewRequest("PUT", url, nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
assert.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&result)
assert.NoError(t, err)
assert.Equal(t, "Invitation resent successfully", result["message"])
})
// Test non-existent invitation
t.Run("ResendInvitation_NotFound", func(t *testing.T) {
url := fmt.Sprintf("%s%s/user/teams/%s/invitations/non-existent-invitation/resend", serverURL, baseURL, teamID)
req, err := http.NewRequest("PUT", url, nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
assert.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
})
// Test unauthorized access
t.Run("ResendInvitation_Unauthorized", func(t *testing.T) {
url := fmt.Sprintf("%s%s/user/teams/%s/invitations/%s/resend", serverURL, baseURL, teamID, invitationID)
req, err := http.NewRequest("PUT", url, nil)
assert.NoError(t, err)
// No Authorization header
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
assert.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
}
// TestMultipleInvitationCreation tests creating multiple invitations for unregistered users
func TestMultipleInvitationCreation(t *testing.T) {
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 test client
client := testutils.RegisterTestClient(t, "Multiple Invitation Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
// Get access token
tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
// Use UUID to ensure unique identifiers
testUUID := strings.ReplaceAll(uuid.New().String(), "-", "")[:8]
// Create test team
createdTeam := createTestTeam(t, serverURL, baseURL, tokenInfo.AccessToken, "Multiple Invitation Test Team "+testUUID)
teamID := getTeamID(createdTeam)
// Test creating multiple invitations for unregistered users
t.Run("CreateMultipleInvitations", func(t *testing.T) {
invitationIDs := make([]string, 0)
for i := 0; i < 3; i++ {
t.Logf("Creating invitation %d", i+1)
// Create invitation data with different messages to ensure uniqueness
invitationData := map[string]interface{}{
"member_type": "user",
"role_id": "user",
"message": fmt.Sprintf("Test invitation %d - %s", i+1, testUUID),
"user_id": nil, // Explicitly set to nil for unregistered users
}
// Convert to JSON
jsonData, err := json.Marshal(invitationData)
assert.NoError(t, err)
// Make API call
url := fmt.Sprintf("%s%s/user/teams/%s/invitations", serverURL, baseURL, teamID)
t.Logf("Request URL: %s", url)
t.Logf("Request data: %s", string(jsonData))
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
assert.NoError(t, err)
defer resp.Body.Close()
// Read response
bodyBytes, err := io.ReadAll(resp.Body)
assert.NoError(t, err)
t.Logf("Response status: %d", resp.StatusCode)
t.Logf("Response body: %s", string(bodyBytes))
if resp.StatusCode != http.StatusCreated {
t.Errorf("Expected 201 but got %d for invitation %d: %s", resp.StatusCode, i+1, string(bodyBytes))
continue
}
var result map[string]interface{}
err = json.Unmarshal(bodyBytes, &result)
assert.NoError(t, err)
invitationID, ok := result["invitation_id"].(string)
assert.True(t, ok, "invitation_id should be a string")
assert.NotEmpty(t, invitationID, "invitation_id should not be empty")
invitationIDs = append(invitationIDs, invitationID)
t.Logf("Successfully created invitation %d with ID: %s", i+1, invitationID)
}
// Verify we created all 3 invitations
assert.Equal(t, 3, len(invitationIDs), "Should have created 3 invitations")
// Verify all invitation IDs are unique
uniqueIDs := make(map[string]bool)
for _, id := range invitationIDs {
assert.False(t, uniqueIDs[id], "Invitation ID should be unique: %s", id)
uniqueIDs[id] = true
}
})
}
// TestInvitationDelete tests the DELETE /user/teams/:team_id/invitations/:invitation_id endpoint
func TestInvitationDelete(t *testing.T) {
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 test client
client := testutils.RegisterTestClient(t, "Invitation Delete Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
// Get access token
tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
// Use UUID to ensure unique identifiers
testUUID := strings.ReplaceAll(uuid.New().String(), "-", "")[:8]
// Create test team
createdTeam := createTestTeam(t, serverURL, baseURL, tokenInfo.AccessToken, "Invitation Delete Test Team "+testUUID)
teamID := getTeamID(createdTeam)
// Create test invitation
invitationID := createTestInvitation(t, serverURL, baseURL, tokenInfo.AccessToken, teamID, "") // Unregistered user
// Test successful delete
t.Run("DeleteInvitation_Success", func(t *testing.T) {
url := fmt.Sprintf("%s%s/user/teams/%s/invitations/%s", serverURL, baseURL, teamID, invitationID)
req, err := http.NewRequest("DELETE", url, nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
assert.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&result)
assert.NoError(t, err)
assert.Equal(t, "Invitation cancelled successfully", result["message"])
// Verify invitation is deleted by trying to get it
getURL := fmt.Sprintf("%s%s/user/teams/%s/invitations/%s", serverURL, baseURL, teamID, invitationID)
getReq, err := http.NewRequest("GET", getURL, nil)
assert.NoError(t, err)
getReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
getResp, err := client.Do(getReq)
assert.NoError(t, err)
defer getResp.Body.Close()
assert.Equal(t, http.StatusNotFound, getResp.StatusCode)
})
// Test non-existent invitation
t.Run("DeleteInvitation_NotFound", func(t *testing.T) {
url := fmt.Sprintf("%s%s/user/teams/%s/invitations/non-existent-invitation", serverURL, baseURL, teamID)
req, err := http.NewRequest("DELETE", url, nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
assert.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
})
// Test unauthorized access
t.Run("DeleteInvitation_Unauthorized", func(t *testing.T) {
// Create another invitation for this test
anotherInvitationID := createTestInvitation(t, serverURL, baseURL, tokenInfo.AccessToken, teamID, "") // Unregistered user
url := fmt.Sprintf("%s%s/user/teams/%s/invitations/%s", serverURL, baseURL, teamID, anotherInvitationID)
req, err := http.NewRequest("DELETE", url, nil)
assert.NoError(t, err)
// No Authorization header
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
assert.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
}
// Helper functions
// createTestInvitation creates a test invitation and returns its ID
func createTestInvitation(t *testing.T, serverURL, baseURL, accessToken, teamID, userID string) string {
return createTestInvitationWithMessage(t, serverURL, baseURL, accessToken, teamID, userID, "Test invitation")
}
// createTestInvitationWithMessage creates a test invitation with custom message and returns its ID
func createTestInvitationWithMessage(t *testing.T, serverURL, baseURL, accessToken, teamID, userID, message string) string {
return createTestInvitationWithRoleAndMessage(t, serverURL, baseURL, accessToken, teamID, userID, "user", message)
}
// createTestInvitationWithRoleAndMessage creates a test invitation with custom role and message and returns its ID
func createTestInvitationWithRoleAndMessage(t *testing.T, serverURL, baseURL, accessToken, teamID, userID, roleID, message string) string {
invitationData := map[string]interface{}{
"member_type": "user",
"role_id": roleID,
"message": message,
}
// Set user_id - nil for unregistered users, actual ID for registered users
if userID != "" {
invitationData["user_id"] = userID
} else {
invitationData["user_id"] = nil // Explicitly set to nil for unregistered users
}
jsonData, _ := json.Marshal(invitationData)
url := fmt.Sprintf("%s%s/user/teams/%s/invitations", serverURL, baseURL, teamID)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+accessToken)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
assert.NoError(t, err)
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
t.Logf("Request data: %s", string(jsonData))
t.Logf("Request URL: %s", url)
t.Logf("Response status: %d", resp.StatusCode)
t.Logf("Response body: %v", body)
t.Fatalf("Expected 201 but got %d: %v", resp.StatusCode, body)
}
var result map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&result)
assert.NoError(t, err)
invitationID, ok := result["invitation_id"].(string)
if !ok {
t.Fatalf("invitation_id not found in response: %v", result)
}
assert.NotEmpty(t, invitationID)
return invitationID
}

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
# User Module TODO
## ✅ Implemented (5/80)
## ✅ Implemented (20/80)
### Authentication
@ -13,7 +13,33 @@
- ✅ POST `/user/oauth/:provider/authorize/prepare` - Handle OAuth POST callback (Apple, WeChat)
- ✅ POST `/user/oauth/:provider/callback` - Handle OAuth GET callback (Google, GitHub)
## ❌ TODO (75/80)
### Team Management (15 endpoints)
#### Team CRUD (5 endpoints)
- ✅ GET `/user/teams` - Get user teams
- ✅ GET `/user/teams/:team_id` - Get user team details
- ✅ POST `/user/teams` - Create user team
- ✅ PUT `/user/teams/:team_id` - Update user team
- ✅ DELETE `/user/teams/:team_id` - Delete user team
#### Member Management (5 endpoints)
- ✅ GET `/user/teams/:team_id/members` - Get user team members
- ✅ GET `/user/teams/:team_id/members/:member_id` - Get user team member details
- ✅ POST `/user/teams/:team_id/members/direct` - Add member directly (for bots/system)
- ✅ PUT `/user/teams/:team_id/members/:member_id` - Update user team member
- ✅ DELETE `/user/teams/:team_id/members/:member_id` - Remove user team member
#### Invitation Management (5 endpoints)
- ✅ POST `/user/teams/:team_id/invitations` - Send team invitation
- ✅ GET `/user/teams/:team_id/invitations` - Get team invitations
- ✅ GET `/user/teams/:team_id/invitations/:invitation_id` - Get invitation details
- ✅ PUT `/user/teams/:team_id/invitations/:invitation_id/resend` - Resend invitation
- ✅ DELETE `/user/teams/:team_id/invitations/:invitation_id` - Cancel invitation
## ❌ TODO (60/80)
### Authentication
@ -67,12 +93,6 @@
- ❌ Referral codes, statistics, history, commissions
### Team Management (15 endpoints)
- ❌ Team CRUD (5 endpoints)
- ❌ Member management (5 endpoints)
- ❌ Invitation management (5 endpoints)
### Invitation Response (3 endpoints)
- ❌ Cross-module invitation handling
@ -91,6 +111,30 @@
## Progress Summary
- **Completion**: 6.25% (5/80)
- **Core Features**: Authentication and OAuth completed
- **Next Steps**: Recommend implementing basic user management (register, logout, profile) first
- **Completion**: 25% (20/80)
- **Core Features**:
- ✅ Authentication and OAuth completed
- ✅ **Team Management completed** (15 endpoints)
- Full team CRUD operations with permission control
- Complete member management with role-based access
- Comprehensive invitation system with support for unregistered users
- Automatic member cleanup on team deletion
- Business ID-based operations for better API design
- **Next Steps**: Recommend implementing basic user management (register, logout, profile) next
## Recent Achievements
### Team Management System (v1.0) 🎉
- **Full Implementation**: All 15 team management endpoints are fully implemented and tested
- **Advanced Features**:
- Multi-invitation support for unregistered users
- Automatic owner membership creation
- Role-based permission system (owner/member access control)
- Business ID abstraction for better API design
- Comprehensive error handling and validation
- **Quality Assurance**:
- 100+ unit tests covering all scenarios
- Complete integration test suite
- Following testutils.go guidelines
- No regressions in existing functionality

879
openapi/user/invitation.go Normal file
View file

@ -0,0 +1,879 @@
package user
import (
"context"
"crypto/rand"
"encoding/base64"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/yaoapp/gou/model"
"github.com/yaoapp/gou/process"
"github.com/yaoapp/kun/exception"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/kun/maps"
"github.com/yaoapp/yao/openapi/oauth"
"github.com/yaoapp/yao/openapi/response"
)
// Team Invitation Management Handlers
// GinInvitationList handles GET /teams/:team_id/invitations - Get team invitations
func GinInvitationList(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("team_id")
if teamID == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Team ID is required",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
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
}
}
if ps := c.Query("pagesize"); ps != "" {
if parsed, err := strconv.Atoi(ps); err == nil && parsed > 0 && parsed <= 100 {
pagesize = parsed
}
}
// Call business logic
result, err := invitationList(c.Request.Context(), authInfo.UserID, teamID, page, pagesize, c.Query("status"))
if err != nil {
log.Error("Failed to get team invitations: %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: "Failed to retrieve team invitations",
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
}
return
}
// Return the paginated result
c.JSON(http.StatusOK, result)
}
// GinInvitationGet handles GET /teams/:team_id/invitations/:invitation_id - Get invitation details
func GinInvitationGet(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("team_id")
invitationID := c.Param("invitation_id")
if teamID == "" || invitationID == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Team ID and Invitation ID are required",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Call business logic
invitationData, err := invitationGet(c.Request.Context(), authInfo.UserID, teamID, invitationID)
if err != nil {
log.Error("Failed to get invitation details: %v", err)
// Check error type for appropriate response
if strings.Contains(err.Error(), "not found") {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Invitation 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: "Failed to retrieve invitation details",
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
}
return
}
// Convert to response format
invitation := mapToInvitationDetailResponse(invitationData)
c.JSON(http.StatusOK, invitation)
}
// GinInvitationCreate handles POST /teams/:team_id/invitations - Send team invitation
func GinInvitationCreate(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("team_id")
if teamID == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Team ID is required",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Parse request body
var req CreateInvitationRequest
if err := c.ShouldBindJSON(&req); err != nil {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Invalid request body: " + err.Error(),
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Prepare invitation data
invitationData := maps.MapStrAny{
"user_id": req.UserID,
"member_type": req.MemberType,
"role_id": req.RoleID,
"message": req.Message,
}
// Add settings if provided
if req.Settings != nil {
invitationData["settings"] = req.Settings
}
// Call business logic
invitationID, err := invitationCreate(c.Request.Context(), authInfo.UserID, teamID, invitationData)
if err != nil {
log.Error("Failed to create invitation: %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 if strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "already invited") {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: err.Error(),
}
response.RespondWithError(c, response.StatusConflict, errorResp)
} else {
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: "Failed to send invitation",
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
}
return
}
// Return created invitation ID
c.JSON(http.StatusCreated, gin.H{"invitation_id": invitationID})
}
// GinInvitationResend handles PUT /teams/:team_id/invitations/:invitation_id/resend - Resend invitation
func GinInvitationResend(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("team_id")
invitationID := c.Param("invitation_id")
if teamID == "" || invitationID == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Team ID and Invitation ID are required",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Call business logic
err := invitationResend(c.Request.Context(), authInfo.UserID, teamID, invitationID)
if err != nil {
log.Error("Failed to resend invitation: %v", err)
// Check error type for appropriate response
if strings.Contains(err.Error(), "not found") {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Invitation 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 if strings.Contains(err.Error(), "already accepted") || strings.Contains(err.Error(), "invalid status") {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: err.Error(),
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
} else {
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: "Failed to resend invitation",
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
}
return
}
c.JSON(http.StatusOK, gin.H{"message": "Invitation resent successfully"})
}
// GinInvitationDelete handles DELETE /teams/:team_id/invitations/:invitation_id - Cancel invitation
func GinInvitationDelete(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("team_id")
invitationID := c.Param("invitation_id")
if teamID == "" || invitationID == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Team ID and Invitation ID are required",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Call business logic
err := invitationDelete(c.Request.Context(), authInfo.UserID, teamID, invitationID)
if err != nil {
log.Error("Failed to cancel invitation: %v", err)
// Check error type for appropriate response
if strings.Contains(err.Error(), "not found") {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Invitation 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: "Failed to cancel invitation",
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
}
return
}
c.JSON(http.StatusOK, gin.H{"message": "Invitation cancelled successfully"})
}
// Yao Process Handlers (for Yao application calls)
// ProcessInvitationList user.invitation.list Invitation list processor
// Args[0] string: team_id
// Args[1] map: Query parameters {"status": "pending", "page": 1, "pagesize": 20}
// Return: map: Paginated invitation list
func ProcessInvitationList(process *process.Process) interface{} {
process.ValidateArgNums(2)
// Get user_id from session
userIDStr := GetUserIDFromSession(process)
teamID := process.ArgsString(0)
if teamID == "" {
exception.New("team_id is required", 400).Throw()
}
// Parse query parameters
queryMap := process.ArgsMap(1)
// Parse pagination
page := 1
pagesize := 20
if p, ok := queryMap["page"]; ok {
if pageInt, ok := p.(int); ok && pageInt > 0 {
page = pageInt
}
}
if ps, ok := queryMap["pagesize"]; ok {
if pagesizeInt, ok := ps.(int); ok && pagesizeInt > 0 && pagesizeInt <= 100 {
pagesize = pagesizeInt
}
}
// Get status filter
status := ""
if s, ok := queryMap["status"].(string); ok {
status = s
}
// Get context
ctx := process.Context
if ctx == nil {
ctx = context.Background()
}
// Call business logic
result, err := invitationList(ctx, userIDStr, teamID, page, pagesize, status)
if err != nil {
exception.New("failed to list invitations: %s", 500, err.Error()).Throw()
}
return result
}
// ProcessInvitationGet user.invitation.get Invitation get processor
// Args[0] string: team_id
// Args[1] string: invitation_id
// Return: map: Invitation details
func ProcessInvitationGet(process *process.Process) interface{} {
process.ValidateArgNums(2)
// Get user_id from session
userIDStr := GetUserIDFromSession(process)
teamID := process.ArgsString(0)
invitationID := process.ArgsString(1)
if teamID == "" || invitationID == "" {
exception.New("team_id and invitation_id are required", 400).Throw()
}
// Get context
ctx := process.Context
if ctx == nil {
ctx = context.Background()
}
// Call business logic
result, err := invitationGet(ctx, userIDStr, teamID, invitationID)
if err != nil {
exception.New("failed to get invitation: %s", 500, err.Error()).Throw()
}
return result
}
// ProcessInvitationCreate user.invitation.create Invitation create processor
// Args[0] string: team_id
// Args[1] map: Invitation data {"user_id": "user123", "member_type": "user", "role_id": "member", "message": "...", "settings": {...}}
// Return: map: {"invitation_id": "created_invitation_id"}
func ProcessInvitationCreate(process *process.Process) interface{} {
process.ValidateArgNums(2)
// Get user_id from session
userIDStr := GetUserIDFromSession(process)
teamID := process.ArgsString(0)
invitationData := maps.MapStrAny(process.ArgsMap(1))
if teamID == "" {
exception.New("team_id is required", 400).Throw()
}
// Validate required fields
if _, ok := invitationData["user_id"]; !ok {
exception.New("user_id is required", 400).Throw()
}
if _, ok := invitationData["role_id"]; !ok {
exception.New("role_id is required", 400).Throw()
}
// Get context
ctx := process.Context
if ctx == nil {
ctx = context.Background()
}
// Call business logic
invitationID, err := invitationCreate(ctx, userIDStr, teamID, invitationData)
if err != nil {
exception.New("failed to create invitation: %s", 500, err.Error()).Throw()
}
return map[string]interface{}{
"invitation_id": invitationID,
}
}
// ProcessInvitationResend user.invitation.resend Invitation resend processor
// Args[0] string: team_id
// Args[1] string: invitation_id
// Return: map: {"message": "success"}
func ProcessInvitationResend(process *process.Process) interface{} {
process.ValidateArgNums(2)
// Get user_id from session
userIDStr := GetUserIDFromSession(process)
teamID := process.ArgsString(0)
invitationID := process.ArgsString(1)
if teamID == "" || invitationID == "" {
exception.New("team_id and invitation_id are required", 400).Throw()
}
// Get context
ctx := process.Context
if ctx == nil {
ctx = context.Background()
}
// Call business logic
err := invitationResend(ctx, userIDStr, teamID, invitationID)
if err != nil {
exception.New("failed to resend invitation: %s", 500, err.Error()).Throw()
}
return map[string]interface{}{
"message": "success",
}
}
// ProcessInvitationDelete user.invitation.delete Invitation delete processor
// Args[0] string: team_id
// Args[1] string: invitation_id
// Return: map: {"message": "success"}
func ProcessInvitationDelete(process *process.Process) interface{} {
process.ValidateArgNums(2)
// Get user_id from session
userIDStr := GetUserIDFromSession(process)
teamID := process.ArgsString(0)
invitationID := process.ArgsString(1)
if teamID == "" || invitationID == "" {
exception.New("team_id and invitation_id are required", 400).Throw()
}
// Get context
ctx := process.Context
if ctx == nil {
ctx = context.Background()
}
// Call business logic
err := invitationDelete(ctx, userIDStr, teamID, invitationID)
if err != nil {
exception.New("failed to delete invitation: %s", 500, err.Error()).Throw()
}
return map[string]interface{}{
"message": "success",
}
}
// Private Business Logic Functions (internal use only)
// invitationList handles the business logic for listing team invitations
func invitationList(ctx context.Context, userID, teamID string, page, pagesize int, status string) (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 {
return nil, err
}
// Allow access if user is owner or member
if !isOwner && !isMember {
return nil, fmt.Errorf("access denied: user is not a member of this team")
}
// Get user provider instance
provider, err := getUserProvider()
if err != nil {
return nil, fmt.Errorf("failed to get user provider: %w", err)
}
// Build query parameters for pending invitations
param := model.QueryParam{
Wheres: []model.QueryWhere{
{Column: "team_id", Value: teamID},
{Column: "status", Value: "pending"}, // Only show pending invitations
},
Orders: []model.QueryOrder{
{Column: "invited_at", Option: "desc"},
{Column: "created_at", Option: "desc"},
},
}
// Add additional status filter if provided
if status != "" && status != "pending" {
// Replace the default pending status filter
param.Wheres[1] = model.QueryWhere{
Column: "status",
Value: status,
}
}
// Get paginated invitations (pending members)
result, err := provider.PaginateMembers(ctx, param, page, pagesize)
if err != nil {
return nil, fmt.Errorf("failed to retrieve invitations: %w", err)
}
return result, nil
}
// invitationGet handles the business logic for getting a specific team invitation
func invitationGet(ctx context.Context, userID, teamID, invitationID string) (maps.MapStrAny, 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 nil, err
}
// Allow access if user is owner or member
if !isOwner && !isMember {
return nil, fmt.Errorf("access denied: user is not a member of this team")
}
// Get user provider instance
provider, err := getUserProvider()
if err != nil {
return nil, fmt.Errorf("failed to get user provider: %w", err)
}
// Get invitation details using invitation_id (business key)
invitationData, err := provider.GetMemberByInvitationID(ctx, invitationID)
if err != nil {
return nil, fmt.Errorf("invitation not found: %w", err)
}
// Verify invitation belongs to this team
if toString(invitationData["team_id"]) != teamID {
return nil, fmt.Errorf("invitation not found in this team")
}
// Only return if it's a pending invitation
if toString(invitationData["status"]) != "pending" {
return nil, fmt.Errorf("invitation not found or no longer pending")
}
return invitationData, nil
}
// invitationCreate handles the business logic for creating a team invitation
func invitationCreate(ctx context.Context, userID, teamID string, invitationData 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 send invitations")
}
// Get user provider instance
provider, err := getUserProvider()
if err != nil {
return "", fmt.Errorf("failed to get user provider: %w", err)
}
// Check if user is already a member or has pending invitation (if user_id is provided)
var inviteeUserID string
if invitationData["user_id"] != nil && invitationData["user_id"] != "" {
inviteeUserID = toString(invitationData["user_id"])
exists, err := provider.MemberExists(ctx, teamID, inviteeUserID)
if err != nil {
return "", fmt.Errorf("failed to check member existence: %w", err)
}
if exists {
return "", fmt.Errorf("user is already a member or has a pending invitation")
}
} else {
// For unregistered users, set user_id to nil (NULL in database)
invitationData["user_id"] = nil
}
// Generate invitation token
token, err := generateInvitationToken()
if err != nil {
return "", fmt.Errorf("failed to generate invitation token: %w", err)
}
// Set invitation-specific fields
invitationData["team_id"] = teamID
if invitationData["member_type"] == nil || invitationData["member_type"] == "" {
invitationData["member_type"] = "user"
}
invitationData["status"] = "pending"
invitationData["invited_by"] = userID
invitationData["invited_at"] = time.Now()
invitationData["invitation_token"] = token
invitationData["invitation_expires_at"] = time.Now().Add(7 * 24 * time.Hour) // 7 days expiry
invitationData["created_at"] = time.Now()
invitationData["updated_at"] = time.Now()
// Create invitation (as a pending member)
memberID, 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)
if err != nil {
return "", fmt.Errorf("failed to retrieve created invitation: %w", err)
}
// Get the generated invitation_id
invitationID := toString(createdMember["invitation_id"])
// TODO: Send invitation email/notification here
// This would typically involve calling an email service or notification system
return invitationID, nil
}
// invitationResend handles the business logic for resending a team invitation
func invitationResend(ctx context.Context, userID, teamID, invitationID 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 resend invitations")
}
// Get user provider instance
provider, err := getUserProvider()
if err != nil {
return fmt.Errorf("failed to get user provider: %w", err)
}
// Get existing invitation using invitation_id (business key)
invitationData, err := provider.GetMemberByInvitationID(ctx, invitationID)
if err != nil {
return fmt.Errorf("invitation not found: %w", err)
}
// Verify invitation belongs to this team
if toString(invitationData["team_id"]) != teamID {
return fmt.Errorf("invitation not found in this team")
}
// Check if invitation is still pending
if toString(invitationData["status"]) != "pending" {
return fmt.Errorf("invitation is no longer pending and cannot be resent")
}
// Generate new invitation token
newToken, err := generateInvitationToken()
if err != nil {
return fmt.Errorf("failed to generate new invitation token: %w", err)
}
// Update invitation with new token and extended expiry
updateData := maps.MapStrAny{
"invitation_token": newToken,
"invitation_expires_at": time.Now().Add(7 * 24 * time.Hour), // Extend for another 7 days
"invited_at": time.Now(), // Update invitation time
"updated_at": time.Now(),
}
// Update invitation using invitation_id
err = provider.UpdateMemberByInvitationID(ctx, invitationID, updateData)
if err != nil {
return fmt.Errorf("failed to update invitation: %w", err)
}
// TODO: Send new invitation email/notification here
// This would typically involve calling an email service or notification system
return nil
}
// invitationDelete handles the business logic for cancelling a team invitation
func invitationDelete(ctx context.Context, userID, teamID, invitationID 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 cancel invitations")
}
// Get user provider instance
provider, err := getUserProvider()
if err != nil {
return fmt.Errorf("failed to get user provider: %w", err)
}
// Get existing invitation using invitation_id (business key)
invitationData, err := provider.GetMemberByInvitationID(ctx, invitationID)
if err != nil {
return fmt.Errorf("invitation not found: %w", err)
}
// Verify invitation belongs to this team
if toString(invitationData["team_id"]) != teamID {
return fmt.Errorf("invitation not found in this team")
}
// Check if invitation is still pending
if toString(invitationData["status"]) != "pending" {
return fmt.Errorf("invitation is no longer pending and cannot be cancelled")
}
// Remove the pending invitation (delete the member record)
err = provider.RemoveMemberByInvitationID(ctx, invitationID)
if err != nil {
return fmt.Errorf("failed to cancel invitation: %w", err)
}
return nil
}
// Private Helper Functions (internal use only)
// generateInvitationToken generates a secure random token for invitations
func generateInvitationToken() (string, error) {
bytes := make([]byte, 32) // 32 bytes = 256 bits
_, err := rand.Read(bytes)
if err != nil {
return "", err
}
// Use URL-safe base64 encoding and remove padding
return strings.TrimRight(base64.URLEncoding.EncodeToString(bytes), "="), nil
}
// mapToInvitationResponse converts a map to InvitationResponse
func mapToInvitationResponse(data maps.MapStr) InvitationResponse {
invitation := InvitationResponse{
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"]),
InvitationToken: toString(data["invitation_token"]),
InvitationExpiresAt: toTimeString(data["invitation_expires_at"]),
Message: toString(data["message"]),
CreatedAt: toTimeString(data["created_at"]),
UpdatedAt: toTimeString(data["updated_at"]),
}
// Add settings if available
if settings, ok := data["settings"]; ok {
if settingsMap, ok := settings.(map[string]interface{}); ok {
invitation.Settings = settingsMap
}
}
return invitation
}
// mapToInvitationDetailResponse converts a map to InvitationDetailResponse
func mapToInvitationDetailResponse(data maps.MapStr) InvitationDetailResponse {
invitation := InvitationDetailResponse{
InvitationResponse: mapToInvitationResponse(data),
}
// Add user info if available (could be joined from user table)
if userInfo, ok := data["user_info"]; ok {
if userInfoMap, ok := userInfo.(map[string]interface{}); ok {
invitation.UserInfo = userInfoMap
}
}
// Add team info if available (could be joined from team table)
if teamInfo, ok := data["team_info"]; ok {
if teamInfoMap, ok := teamInfo.(map[string]interface{}); ok {
invitation.TeamInfo = teamInfoMap
}
}
return invitation
}

822
openapi/user/member.go Normal file
View file

@ -0,0 +1,822 @@
package user
import (
"context"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/yaoapp/gou/model"
"github.com/yaoapp/gou/process"
"github.com/yaoapp/kun/exception"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/kun/maps"
"github.com/yaoapp/yao/openapi/oauth"
"github.com/yaoapp/yao/openapi/response"
)
// Member Management Handlers
// GinMemberList handles GET /teams/:team_id/members - Get team members
func GinMemberList(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("team_id")
if teamID == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Team ID is required",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
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
}
}
if ps := c.Query("pagesize"); ps != "" {
if parsed, err := strconv.Atoi(ps); err == nil && parsed > 0 && parsed <= 100 {
pagesize = parsed
}
}
// Call business logic
result, err := memberList(c.Request.Context(), authInfo.UserID, teamID, page, pagesize, c.Query("status"))
if err != nil {
log.Error("Failed to get team members: %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: "Failed to retrieve team members",
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
}
return
}
// Return the paginated result
c.JSON(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
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("team_id")
memberID := c.Param("member_id")
if teamID == "" || memberID == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Team ID and Member ID are required",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Call business logic
memberData, err := memberGet(c.Request.Context(), authInfo.UserID, teamID, memberID)
if err != nil {
log.Error("Failed to get member details: %v", err)
// Check error type for appropriate response
if strings.Contains(err.Error(), "not found") {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Member 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: "Failed to retrieve member details",
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
}
return
}
// Convert to response format
member := mapToMemberDetailResponse(memberData)
c.JSON(http.StatusOK, member)
}
// GinMemberCreateDirect handles POST /teams/:team_id/members/direct - Add member directly
func GinMemberCreateDirect(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("team_id")
if teamID == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Team ID is required",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Parse request body
var req CreateMemberRequest
if err := c.ShouldBindJSON(&req); err != nil {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Invalid request body: " + err.Error(),
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Prepare member data
memberData := maps.MapStrAny{
"user_id": req.UserID,
"member_type": req.MemberType,
"role_id": req.RoleID,
}
// Add settings if provided
if req.Settings != nil {
memberData["settings"] = req.Settings
}
// Call business logic
memberID, err := memberCreateDirect(c.Request.Context(), authInfo.UserID, teamID, memberData)
if err != nil {
log.Error("Failed to create member: %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 if strings.Contains(err.Error(), "already exists") {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: err.Error(),
}
response.RespondWithError(c, response.StatusConflict, errorResp)
} else {
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: "Failed to create member",
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
}
return
}
// Return created member ID
c.JSON(http.StatusCreated, gin.H{"member_id": memberID})
}
// GinMemberUpdate handles PUT /teams/:team_id/members/:member_id - Update team member
func GinMemberUpdate(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("team_id")
memberID := c.Param("member_id")
if teamID == "" || memberID == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Team ID and Member ID are required",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Parse request body
var req UpdateMemberRequest
if err := c.ShouldBindJSON(&req); err != nil {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Invalid request body: " + err.Error(),
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Prepare update data
updateData := maps.MapStrAny{}
if req.RoleID != "" {
updateData["role_id"] = req.RoleID
}
if req.Status != "" {
updateData["status"] = req.Status
}
if req.Settings != nil {
updateData["settings"] = req.Settings
}
if req.LastActivity != "" {
updateData["last_activity"] = req.LastActivity
}
// Call business logic
err := memberUpdate(c.Request.Context(), authInfo.UserID, teamID, memberID, updateData)
if err != nil {
log.Error("Failed to update member: %v", err)
// Check error type for appropriate response
if strings.Contains(err.Error(), "not found") {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Member 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: "Failed to update member",
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
}
return
}
c.JSON(http.StatusOK, gin.H{"message": "Member updated successfully"})
}
// GinMemberDelete handles DELETE /teams/:team_id/members/:member_id - Remove team member
func GinMemberDelete(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("team_id")
memberID := c.Param("member_id")
if teamID == "" || memberID == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Team ID and Member ID are required",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Call business logic
err := memberDelete(c.Request.Context(), authInfo.UserID, teamID, memberID)
if err != nil {
log.Error("Failed to delete member: %v", err)
// Check error type for appropriate response
if strings.Contains(err.Error(), "not found") {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Member 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: "Failed to delete member",
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
}
return
}
c.JSON(http.StatusOK, gin.H{"message": "Member removed successfully"})
}
// Yao Process Handlers (for Yao application calls)
// ProcessMemberList user.member.list Member list processor
// Args[0] string: team_id
// Args[1] map: Query parameters {"status": "active", "page": 1, "pagesize": 20}
// Return: map: Paginated member list
func ProcessMemberList(process *process.Process) interface{} {
process.ValidateArgNums(2)
// Get user_id from session
userIDStr := GetUserIDFromSession(process)
teamID := process.ArgsString(0)
if teamID == "" {
exception.New("team_id is required", 400).Throw()
}
// Parse query parameters
queryMap := process.ArgsMap(1)
// Parse pagination
page := 1
pagesize := 20
if p, ok := queryMap["page"]; ok {
if pageInt, ok := p.(int); ok && pageInt > 0 {
page = pageInt
}
}
if ps, ok := queryMap["pagesize"]; ok {
if pagesizeInt, ok := ps.(int); ok && pagesizeInt > 0 && pagesizeInt <= 100 {
pagesize = pagesizeInt
}
}
// Get status filter
status := ""
if s, ok := queryMap["status"].(string); ok {
status = s
}
// Get context
ctx := process.Context
if ctx == nil {
ctx = context.Background()
}
// Call business logic
result, err := memberList(ctx, userIDStr, teamID, page, pagesize, status)
if err != nil {
exception.New("failed to list members: %s", 500, err.Error()).Throw()
}
return result
}
// ProcessMemberGet user.member.get Member get processor
// Args[0] string: team_id
// Args[1] string: member_id
// Return: map: Member details
func ProcessMemberGet(process *process.Process) interface{} {
process.ValidateArgNums(2)
// Get user_id from session
userIDStr := GetUserIDFromSession(process)
teamID := process.ArgsString(0)
memberID := process.ArgsString(1)
if teamID == "" || memberID == "" {
exception.New("team_id and member_id are required", 400).Throw()
}
// Get context
ctx := process.Context
if ctx == nil {
ctx = context.Background()
}
// Call business logic
result, err := memberGet(ctx, userIDStr, teamID, memberID)
if err != nil {
exception.New("failed to get member: %s", 500, err.Error()).Throw()
}
return result
}
// ProcessMemberCreateDirect user.member.create Member create processor
// Args[0] string: team_id
// Args[1] map: Member data {"user_id": "user123", "member_type": "user", "role_id": "member", "settings": {...}}
// Return: map: {"member_id": "created_member_id"}
func ProcessMemberCreateDirect(process *process.Process) interface{} {
process.ValidateArgNums(2)
// Get user_id from session
userIDStr := GetUserIDFromSession(process)
teamID := process.ArgsString(0)
memberData := maps.MapStrAny(process.ArgsMap(1))
if teamID == "" {
exception.New("team_id is required", 400).Throw()
}
// Validate required fields
if _, ok := memberData["user_id"]; !ok {
exception.New("user_id is required", 400).Throw()
}
if _, ok := memberData["role_id"]; !ok {
exception.New("role_id is required", 400).Throw()
}
// Get context
ctx := process.Context
if ctx == nil {
ctx = context.Background()
}
// Call business logic
memberID, err := memberCreateDirect(ctx, userIDStr, teamID, memberData)
if err != nil {
exception.New("failed to create member: %s", 500, err.Error()).Throw()
}
return map[string]interface{}{
"member_id": memberID,
}
}
// ProcessMemberUpdate user.member.update Member update processor
// Args[0] string: team_id
// Args[1] string: member_id
// Args[2] map: Update data {"role_id": "admin", "status": "active", "settings": {...}}
// Return: map: {"message": "success"}
func ProcessMemberUpdate(process *process.Process) interface{} {
process.ValidateArgNums(3)
// Get user_id from session
userIDStr := GetUserIDFromSession(process)
teamID := process.ArgsString(0)
memberID := process.ArgsString(1)
updateData := maps.MapStrAny(process.ArgsMap(2))
if teamID == "" || memberID == "" {
exception.New("team_id and member_id are required", 400).Throw()
}
// Get context
ctx := process.Context
if ctx == nil {
ctx = context.Background()
}
// Call business logic
err := memberUpdate(ctx, userIDStr, teamID, memberID, updateData)
if err != nil {
exception.New("failed to update member: %s", 500, err.Error()).Throw()
}
return map[string]interface{}{
"message": "success",
}
}
// ProcessMemberDelete user.member.delete Member delete processor
// Args[0] string: team_id
// Args[1] string: member_id
// Return: map: {"message": "success"}
func ProcessMemberDelete(process *process.Process) interface{} {
process.ValidateArgNums(2)
// Get user_id from session
userIDStr := GetUserIDFromSession(process)
teamID := process.ArgsString(0)
memberID := process.ArgsString(1)
if teamID == "" || memberID == "" {
exception.New("team_id and member_id are required", 400).Throw()
}
// Get context
ctx := process.Context
if ctx == nil {
ctx = context.Background()
}
// Call business logic
err := memberDelete(ctx, userIDStr, teamID, memberID)
if err != nil {
exception.New("failed to delete member: %s", 500, err.Error()).Throw()
}
return map[string]interface{}{
"message": "success",
}
}
// 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) {
// Check if user has access to the team (read permission: owner or member)
isOwner, isMember, err := checkTeamAccess(ctx, teamID, userID)
if err != nil {
return nil, err
}
// Allow access if user is owner or member
if !isOwner && !isMember {
return nil, fmt.Errorf("access denied: user is not a member of this team")
}
// Get user provider instance
provider, err := getUserProvider()
if err != nil {
return nil, fmt.Errorf("failed to get user provider: %w", err)
}
// Build query parameters
param := model.QueryParam{
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 != "" {
param.Wheres = append(param.Wheres, model.QueryWhere{
Column: "status",
Value: status,
})
}
// Get paginated members
result, err := provider.PaginateMembers(ctx, param, page, pagesize)
if err != nil {
return nil, fmt.Errorf("failed to retrieve members: %w", err)
}
return result, nil
}
// memberGet handles the business logic for getting a specific team member
func memberGet(ctx context.Context, userID, teamID, memberID string) (maps.MapStrAny, 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 nil, err
}
// Allow access if user is owner or member
if !isOwner && !isMember {
return nil, fmt.Errorf("access denied: user is not a member of this team")
}
// Get user provider instance
provider, err := getUserProvider()
if err != nil {
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)
if err != nil {
return nil, fmt.Errorf("member not found: %w", err)
}
return memberData, nil
}
// memberCreateDirect handles the business logic for creating a team member directly
func memberCreateDirect(ctx context.Context, userID, teamID string, memberData maps.MapStrAny) (int64, error) {
// Check if user has access to the team (write permission: owner only)
isOwner, _, err := checkTeamAccess(ctx, teamID, userID)
if err != nil {
return 0, err
}
// Only allow access if user is owner
if !isOwner {
return 0, fmt.Errorf("access denied: only team owner can add members")
}
// Get user provider instance
provider, err := getUserProvider()
if err != nil {
return 0, fmt.Errorf("failed to get user provider: %w", err)
}
// Check if member already exists
memberUserID := toString(memberData["user_id"])
exists, err := provider.MemberExists(ctx, teamID, memberUserID)
if err != nil {
return 0, fmt.Errorf("failed to check member existence: %w", err)
}
if exists {
return 0, fmt.Errorf("member already exists in this team")
}
// Set team ID and default values
memberData["team_id"] = teamID
if memberData["member_type"] == nil || memberData["member_type"] == "" {
memberData["member_type"] = "user"
}
memberData["status"] = "active"
memberData["joined_at"] = time.Now()
memberData["created_at"] = time.Now()
memberData["updated_at"] = time.Now()
// Create member
memberID, err := provider.CreateMember(ctx, memberData)
if err != nil {
return 0, fmt.Errorf("failed to create member: %w", err)
}
return memberID, nil
}
// memberUpdate handles the business logic for updating a team member
func memberUpdate(ctx context.Context, userID, teamID, memberUserID 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 {
return err
}
// Only allow access if user is owner
if !isOwner {
return fmt.Errorf("access denied: only team owner can update members")
}
// Get user provider instance
provider, err := getUserProvider()
if err != nil {
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)
if err != nil {
return fmt.Errorf("member not found: %w", err)
}
// Add updated_at timestamp
updateData["updated_at"] = time.Now()
// Update member using team_id + user_id
err = provider.UpdateMember(ctx, teamID, memberUserID, updateData)
if err != nil {
return fmt.Errorf("failed to update member: %w", err)
}
return nil
}
// memberDelete handles the business logic for deleting a team member
func memberDelete(ctx context.Context, userID, teamID, memberID 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 remove members")
}
// Get user provider instance
provider, err := getUserProvider()
if err != nil {
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)
if err != nil {
return fmt.Errorf("member not found: %w", err)
}
// Remove member using team_id + user_id
err = provider.RemoveMember(ctx, teamID, memberID)
if err != nil {
return fmt.Errorf("failed to delete member: %w", err)
}
return nil
}
// Private Helper Functions (internal use only)
// checkTeamAccess checks if user has access to the team
// Returns: (isOwner bool, isMember bool, error)
func checkTeamAccess(ctx context.Context, teamID, userID string) (bool, bool, error) {
// Get user provider instance
provider, err := getUserProvider()
if err != nil {
return false, false, fmt.Errorf("failed to get user provider: %w", err)
}
// Use UserProvider's CheckTeamAccess method - note parameter order: (ctx, teamID, userID)
return provider.CheckTeamAccess(ctx, teamID, userID)
}
// 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"]),
}
// Add settings if available
if settings, ok := data["settings"]; ok {
if settingsMap, ok := settings.(map[string]interface{}); ok {
member.Settings = settingsMap
}
}
return member
}
// mapToMemberDetailResponse converts a map to MemberDetailResponse
func mapToMemberDetailResponse(data maps.MapStr) MemberDetailResponse {
member := MemberDetailResponse{
MemberResponse: mapToMemberResponse(data),
}
// Add user info if available (could be joined from user table)
if userInfo, ok := data["user_info"]; ok {
if userInfoMap, ok := userInfo.(map[string]interface{}); ok {
member.UserInfo = userInfoMap
}
}
return member
}

View file

@ -238,3 +238,82 @@ type UpdateTeamRequest struct {
Description string `json:"description,omitempty"`
Settings map[string]interface{} `json:"settings,omitempty"`
}
// ==== Member API Types ====
// 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 map[string]interface{} `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
UserInfo map[string]interface{} `json:"user_info,omitempty"`
}
// CreateMemberRequest represents the request to add a member directly
type CreateMemberRequest struct {
UserID string `json:"user_id" binding:"required"`
MemberType string `json:"member_type,omitempty"` // "user" or "robot"
RoleID string `json:"role_id" binding:"required"`
Settings map[string]interface{} `json:"settings,omitempty"`
}
// UpdateMemberRequest represents the request to update a member
type UpdateMemberRequest struct {
RoleID string `json:"role_id,omitempty"`
Status string `json:"status,omitempty"`
Settings map[string]interface{} `json:"settings,omitempty"`
LastActivity string `json:"last_activity,omitempty"`
}
// ==== Invitation API Types ====
// InvitationResponse represents a team invitation in API responses
type InvitationResponse 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"`
InvitedAt string `json:"invited_at"`
InvitationToken string `json:"invitation_token,omitempty"`
InvitationExpiresAt string `json:"invitation_expires_at,omitempty"`
Message string `json:"message,omitempty"`
Settings map[string]interface{} `json:"settings,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// InvitationDetailResponse represents detailed invitation information
type InvitationDetailResponse struct {
InvitationResponse
// Add additional fields that are only included in detailed responses
UserInfo map[string]interface{} `json:"user_info,omitempty"`
TeamInfo map[string]interface{} `json:"team_info,omitempty"`
}
// CreateInvitationRequest represents the request to send a team invitation
type CreateInvitationRequest struct {
UserID string `json:"user_id,omitempty"` // Optional for unregistered users
MemberType string `json:"member_type,omitempty"` // "user" or "robot"
RoleID string `json:"role_id" binding:"required"`
Message string `json:"message,omitempty"`
Settings map[string]interface{} `json:"settings,omitempty"`
}

View file

@ -62,18 +62,18 @@ func attachTeam(group *gin.RouterGroup, oauth types.OAuth) {
team.DELETE("/:team_id", GinTeamDelete) // Delete user team
// Member Management
team.GET("/:team_id/members", placeholder) // Get user team members
team.GET("/:team_id/members/:member_id", placeholder) // Get user team member details
team.POST("/:team_id/members/direct", placeholder) // Add member directly (for bots/system)
team.PUT("/:team_id/members/:member_id", placeholder) // Update user team member
team.DELETE("/:team_id/members/:member_id", placeholder) // Remove user team member
team.GET("/:team_id/members", GinMemberList) // Get user team members
team.GET("/:team_id/members/:member_id", GinMemberGet) // Get user team member details
team.POST("/:team_id/members/direct", GinMemberCreateDirect) // Add member directly (for bots/system)
team.PUT("/:team_id/members/:member_id", GinMemberUpdate) // Update user team member
team.DELETE("/:team_id/members/:member_id", GinMemberDelete) // Remove user team member
// Member Invitation Management
team.POST("/:team_id/invitations", placeholder) // Send team invitation
team.GET("/:team_id/invitations", placeholder) // Get team invitations
team.GET("/:team_id/invitations/:invitation_id", placeholder) // Get invitation details
team.PUT("/:team_id/invitations/:invitation_id/resend", placeholder) // Resend invitation
team.DELETE("/:team_id/invitations/:invitation_id", placeholder) // Cancel invitation
team.POST("/:team_id/invitations", GinInvitationCreate) // Send team invitation
team.GET("/:team_id/invitations", GinInvitationList) // Get team invitations
team.GET("/:team_id/invitations/:invitation_id", GinInvitationGet) // Get invitation details
team.PUT("/:team_id/invitations/:invitation_id/resend", GinInvitationResend) // Resend invitation
team.DELETE("/:team_id/invitations/:invitation_id", GinInvitationDelete) // Cancel invitation
}
// Invitation Response Management (Cross-module invitation handling)

View file

@ -215,6 +215,16 @@
// ============================================================================
// Invitation & Join Information
// ============================================================================
{
"name": "invitation_id",
"type": "string",
"label": "Invitation ID",
"comment": "Unique invitation identifier for pending invitations (business ID for invitations)",
"length": 100,
"nullable": true,
"unique": true,
"index": true
},
{
"name": "invited_by",
"type": "string",
@ -316,9 +326,9 @@
"indexes": [
{
"name": "idx_team_user_unique",
"columns": ["team_id", "user_id"],
"columns": ["team_id", "user_id", "invitation_id"],
"type": "unique",
"comment": "Unique constraint: one user can have only one membership per team (user_id can be null for robots)"
"comment": "Unique constraint: one user can have only one membership per team, with unique invitation_id for pending invitations"
},
{
"name": "idx_team_robot_unique",
@ -368,6 +378,12 @@
"type": "index",
"comment": "Index for managing invitations"
},
{
"name": "idx_invitation_id",
"columns": ["invitation_id", "status", "invitation_expires_at"],
"type": "index",
"comment": "Index for invitation ID lookup and status"
},
{
"name": "idx_invitation_token",
"columns": ["invitation_token", "invitation_expires_at"],