Enhance member invitation management and related functionality
- Introduced invitation_id handling for member management, allowing for unique identification of pending invitations. - Added methods to create, update, and remove members by invitation_id, improving the invitation workflow. - Updated member data structures and API responses to include invitation-related fields, enhancing clarity and usability. - Implemented tests for invitation ID operations, ensuring robust validation and error handling. - Refactored team access checks to streamline member management processes.
This commit is contained in:
parent
35afc1d58c
commit
b9a912b7d9
13 changed files with 2298 additions and 174 deletions
282
data/bindata.go
282
data/bindata.go
File diff suppressed because one or more lines are too long
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
777
openapi/tests/user/invitation_test.go
Normal file
777
openapi/tests/user/invitation_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
879
openapi/user/invitation.go
Normal 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
|
||||
}
|
||||
|
|
@ -570,7 +570,7 @@ func ProcessMemberDelete(process *process.Process) interface{} {
|
|||
// 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, userID, teamID)
|
||||
isOwner, isMember, err := checkTeamAccess(ctx, teamID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -617,7 +617,7 @@ func memberList(ctx context.Context, userID, teamID string, page, pagesize int,
|
|||
// 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, userID, teamID)
|
||||
isOwner, isMember, err := checkTeamAccess(ctx, teamID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -646,7 +646,7 @@ func memberGet(ctx context.Context, userID, teamID, memberID string) (maps.MapSt
|
|||
// 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, userID, teamID)
|
||||
isOwner, _, err := checkTeamAccess(ctx, teamID, userID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
|
@ -694,7 +694,7 @@ func memberCreateDirect(ctx context.Context, userID, teamID string, memberData m
|
|||
// 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, userID, teamID)
|
||||
isOwner, _, err := checkTeamAccess(ctx, teamID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -731,7 +731,7 @@ func memberUpdate(ctx context.Context, userID, teamID, memberUserID string, upda
|
|||
// 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, userID, teamID)
|
||||
isOwner, _, err := checkTeamAccess(ctx, teamID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -767,14 +767,14 @@ func memberDelete(ctx context.Context, userID, teamID, memberID string) error {
|
|||
|
||||
// checkTeamAccess checks if user has access to the team
|
||||
// Returns: (isOwner bool, isMember bool, error)
|
||||
func checkTeamAccess(ctx context.Context, userID, teamID string) (bool, 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
|
||||
// Use UserProvider's CheckTeamAccess method - note parameter order: (ctx, teamID, userID)
|
||||
return provider.CheckTeamAccess(ctx, teamID, userID)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -280,3 +280,40 @@ type UpdateMemberRequest struct {
|
|||
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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,11 +69,11 @@ func attachTeam(group *gin.RouterGroup, oauth types.OAuth) {
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue