From 35afc1d58c2114cc0fca86fa0ad05fff8807d592 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 23 Sep 2025 16:14:42 +0800 Subject: [PATCH] Add team ownership and membership checks in user management - Introduced IsTeamOwner and IsTeamMember methods to verify user ownership and membership status within a team. - Added CheckTeamAccess method to assess a user's access level, returning ownership and membership status. - Enhanced user types with new MemberResponse and MemberDetailResponse structures for improved member management. - Updated routing for team member management to utilize new handler functions for clarity and consistency. --- openapi/oauth/providers/user/team.go | 48 ++ openapi/tests/user/member_test.go | 1024 ++++++++++++++++++++++++++ openapi/user/member.go | 822 +++++++++++++++++++++ openapi/user/types.go | 42 ++ openapi/user/user.go | 10 +- 5 files changed, 1941 insertions(+), 5 deletions(-) create mode 100644 openapi/tests/user/member_test.go create mode 100644 openapi/user/member.go diff --git a/openapi/oauth/providers/user/team.go b/openapi/oauth/providers/user/team.go index 7b7df638..84cc06de 100644 --- a/openapi/oauth/providers/user/team.go +++ b/openapi/oauth/providers/user/team.go @@ -348,3 +348,51 @@ func (u *DefaultUser) TransferTeamOwnership(ctx context.Context, teamID string, return u.UpdateTeam(ctx, teamID, updateData) } + +// IsTeamOwner checks if a user is the owner of a team +func (u *DefaultUser) IsTeamOwner(ctx context.Context, teamID string, userID string) (bool, error) { + teamData, err := u.GetTeam(ctx, teamID) + if err != nil { + return false, fmt.Errorf("failed to get team: %w", err) + } + + ownerID, ok := teamData["owner_id"].(string) + if !ok { + return false, fmt.Errorf("invalid owner_id type in team data") + } + + return ownerID == userID, nil +} + +// IsTeamMember checks if a user is a member of a team (includes owner) +func (u *DefaultUser) IsTeamMember(ctx context.Context, teamID string, userID string) (bool, error) { + // First check if user is the owner + isOwner, err := u.IsTeamOwner(ctx, teamID, userID) + if err != nil { + return false, err + } + if isOwner { + return true, nil + } + + // Then check if user is a member + return u.MemberExists(ctx, teamID, userID) +} + +// CheckTeamAccess checks user's access level to a team +// Returns: (isOwner bool, isMember bool, error) +func (u *DefaultUser) CheckTeamAccess(ctx context.Context, teamID string, userID string) (bool, bool, error) { + // Check if user is the owner + isOwner, err := u.IsTeamOwner(ctx, teamID, userID) + if err != nil { + return false, false, err + } + + // Check if user is a member (this will return true for owner as well, but we already know that) + isMember, err := u.IsTeamMember(ctx, teamID, userID) + if err != nil { + return false, false, err + } + + return isOwner, isMember, nil +} diff --git a/openapi/tests/user/member_test.go b/openapi/tests/user/member_test.go new file mode 100644 index 00000000..3e6313df --- /dev/null +++ b/openapi/tests/user/member_test.go @@ -0,0 +1,1024 @@ +package user_test + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/yaoapp/yao/openapi" + "github.com/yaoapp/yao/openapi/tests/testutils" +) + +// TestMemberList tests the GET /user/teams/:team_id/members endpoint +func TestMemberList(t *testing.T) { + // Initialize test environment + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + // Get base URL from server config + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + // Register a test client for OAuth authentication + testClient := testutils.RegisterTestClient(t, "Member List Test Client", []string{"https://localhost/callback"}) + defer testutils.CleanupTestClient(t, testClient.ClientID) + + // Obtain access token for authenticated requests + tokenInfo := testutils.ObtainAccessToken(t, serverURL, testClient.ClientID, testClient.ClientSecret, "https://localhost/callback", "openid profile") + + // Create a test team first + createdTeam := createTestTeam(t, serverURL, baseURL, tokenInfo.AccessToken, "Member List Test Team") + teamID := getTeamID(createdTeam) + + testCases := []struct { + name string + teamID string + query string + headers map[string]string + expectCode int + expectMsg string + }{ + { + "list members without authentication", + teamID, + "", + map[string]string{}, + 401, + "should require authentication", + }, + { + "list members with valid token", + teamID, + "", + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 200, + "should return team members", + }, + { + "list members with pagination", + teamID, + "?page=1&pagesize=10", + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 200, + "should handle pagination parameters", + }, + { + "list members with status filter", + teamID, + "?status=active", + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 200, + "should filter by status", + }, + { + "list members of non-existent team", + "non-existent-team-id", + "", + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 404, + "should return not found for non-existent team", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + requestURL := serverURL + baseURL + "/user/teams/" + tc.teamID + "/members" + tc.query + req, err := http.NewRequest("GET", requestURL, nil) + assert.NoError(t, err, "Should create HTTP request") + + // Add headers + for key, value := range tc.headers { + req.Header.Set(key, value) + } + + client := &http.Client{} + resp, err := client.Do(req) + assert.NoError(t, err, "HTTP request should succeed") + + if resp != nil { + defer resp.Body.Close() + assert.Equal(t, tc.expectCode, resp.StatusCode, "Expected status code %d for %s", tc.expectCode, tc.name) + + body, err := io.ReadAll(resp.Body) + assert.NoError(t, err, "Should read response body") + + if resp.StatusCode == 200 { + // Parse response as pagination result + var response map[string]interface{} + err = json.Unmarshal(body, &response) + assert.NoError(t, err, "Should parse JSON response") + + // Check pagination structure + assert.Contains(t, response, "data", "Should have data array") + assert.Contains(t, response, "total", "Should have total count") + assert.Contains(t, response, "page", "Should have page number") + assert.Contains(t, response, "pagesize", "Should have pagesize") + + // Verify that creator is automatically added as member + if data, ok := response["data"].([]interface{}); ok { + assert.GreaterOrEqual(t, len(data), 1, "Should have at least the owner as member") + if len(data) > 0 { + member := data[0].(map[string]interface{}) + assert.Equal(t, tokenInfo.UserID, member["user_id"], "Owner should be in member list") + assert.Equal(t, "owner", member["role_id"], "Creator should have owner role") + } + } + } + + t.Logf("Member list test %s: status=%d, body=%s", tc.name, resp.StatusCode, string(body)) + } + }) + } +} + +// TestMemberGet tests the GET /user/teams/:team_id/members/:member_id endpoint +func TestMemberGet(t *testing.T) { + // Initialize test environment + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + // Get base URL from server config + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + // Register a test client for OAuth authentication + testClient := testutils.RegisterTestClient(t, "Member Get Test Client", []string{"https://localhost/callback"}) + defer testutils.CleanupTestClient(t, testClient.ClientID) + + // Obtain access token for authenticated requests + tokenInfo := testutils.ObtainAccessToken(t, serverURL, testClient.ClientID, testClient.ClientSecret, "https://localhost/callback", "openid profile") + + // Create a test team and get owner member ID + createdTeam := createTestTeam(t, serverURL, baseURL, tokenInfo.AccessToken, "Member Get Test Team") + teamID := getTeamID(createdTeam) + + // Get member list to find the owner member ID + ownerMemberID := getOwnerMemberID(t, serverURL, baseURL, teamID, tokenInfo.AccessToken) + + testCases := []struct { + name string + teamID string + memberID string + headers map[string]string + expectCode int + expectMsg string + }{ + { + "get member without authentication", + teamID, + ownerMemberID, + map[string]string{}, + 401, + "should require authentication", + }, + { + "get existing member", + teamID, + ownerMemberID, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 200, + "should return member details", + }, + { + "get non-existent member", + teamID, + "999999", + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 404, + "should return not found for non-existent member", + }, + { + "get member from non-existent team", + "non-existent-team-id", + ownerMemberID, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 404, + "should return not found for non-existent team", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + requestURL := serverURL + baseURL + "/user/teams/" + tc.teamID + "/members/" + tc.memberID + req, err := http.NewRequest("GET", requestURL, nil) + assert.NoError(t, err, "Should create HTTP request") + + // Add headers + for key, value := range tc.headers { + req.Header.Set(key, value) + } + + client := &http.Client{} + resp, err := client.Do(req) + assert.NoError(t, err, "HTTP request should succeed") + + if resp != nil { + defer resp.Body.Close() + assert.Equal(t, tc.expectCode, resp.StatusCode, "Expected status code %d for %s", tc.expectCode, tc.name) + + body, err := io.ReadAll(resp.Body) + assert.NoError(t, err, "Should read response body") + + if resp.StatusCode == 200 { + // Parse response as member object + var member map[string]interface{} + err = json.Unmarshal(body, &member) + assert.NoError(t, err, "Should parse JSON response") + + // Verify member structure + assert.Contains(t, member, "id", "Should have member ID") + assert.Contains(t, member, "team_id", "Should have team_id") + assert.Contains(t, member, "user_id", "Should have user_id") + assert.Contains(t, member, "role_id", "Should have role_id") + assert.Contains(t, member, "status", "Should have status") + assert.Contains(t, member, "created_at", "Should have created_at") + assert.Contains(t, member, "updated_at", "Should have updated_at") + + // Verify values + assert.Equal(t, teamID, member["team_id"], "Should have correct team_id") + assert.Equal(t, tokenInfo.UserID, member["user_id"], "Should have correct user_id") + } + + t.Logf("Member get test %s: status=%d, body=%s", tc.name, resp.StatusCode, string(body)) + } + }) + } +} + +// TestMemberCreateDirect tests the POST /user/teams/:team_id/members/direct endpoint +func TestMemberCreateDirect(t *testing.T) { + // Initialize test environment + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + // Get base URL from server config + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + // Register a test client for OAuth authentication + testClient := testutils.RegisterTestClient(t, "Member Create Test Client", []string{"https://localhost/callback"}) + defer testutils.CleanupTestClient(t, testClient.ClientID) + + // Obtain access token for authenticated requests + tokenInfo := testutils.ObtainAccessToken(t, serverURL, testClient.ClientID, testClient.ClientSecret, "https://localhost/callback", "openid profile") + + // Create a test team + createdTeam := createTestTeam(t, serverURL, baseURL, tokenInfo.AccessToken, "Member Create Test Team") + teamID := getTeamID(createdTeam) + + testCases := []struct { + name string + teamID string + body map[string]interface{} + headers map[string]string + expectCode int + expectMsg string + }{ + { + "create member without authentication", + teamID, + map[string]interface{}{ + "user_id": "test-user-123", + "role_id": "member", + }, + map[string]string{}, + 401, + "should require authentication", + }, + { + "create member with valid data", + teamID, + map[string]interface{}{ + "user_id": "test-user-123", + "member_type": "user", + "role_id": "member", + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 201, + "should create member successfully", + }, + { + "create member with settings", + teamID, + map[string]interface{}{ + "user_id": "test-user-456", + "member_type": "user", + "role_id": "admin", + "settings": map[string]interface{}{ + "notifications": true, + "permissions": []string{"read", "write"}, + }, + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 201, + "should create member with settings", + }, + { + "create member without user_id", + teamID, + map[string]interface{}{ + "role_id": "member", + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 400, + "should require user_id", + }, + { + "create member without role_id", + teamID, + map[string]interface{}{ + "user_id": "test-user-789", + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 400, + "should require role_id", + }, + { + "create duplicate member", + teamID, + map[string]interface{}{ + "user_id": "test-user-123", // Same as first successful case + "role_id": "member", + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 409, + "should reject duplicate member", + }, + { + "create member in non-existent team", + "non-existent-team-id", + map[string]interface{}{ + "user_id": "test-user-999", + "role_id": "member", + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 404, + "should return not found for non-existent team", + }, + { + "create member with invalid JSON", + teamID, + nil, // Will send invalid JSON + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 400, + "should handle invalid JSON", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + requestURL := serverURL + baseURL + "/user/teams/" + tc.teamID + "/members/direct" + + var req *http.Request + var err error + + if tc.body == nil { + // Send invalid JSON for invalid JSON test case + req, err = http.NewRequest("POST", requestURL, bytes.NewBufferString("invalid json")) + } else { + bodyBytes, _ := json.Marshal(tc.body) + req, err = http.NewRequest("POST", requestURL, bytes.NewBuffer(bodyBytes)) + } + assert.NoError(t, err, "Should create HTTP request") + + req.Header.Set("Content-Type", "application/json") + + // Add headers + for key, value := range tc.headers { + req.Header.Set(key, value) + } + + client := &http.Client{} + resp, err := client.Do(req) + assert.NoError(t, err, "HTTP request should succeed") + + if resp != nil { + defer resp.Body.Close() + assert.Equal(t, tc.expectCode, resp.StatusCode, "Expected status code %d for %s", tc.expectCode, tc.name) + + body, err := io.ReadAll(resp.Body) + assert.NoError(t, err, "Should read response body") + + if resp.StatusCode == 201 { + // Parse response as created member + var response map[string]interface{} + err = json.Unmarshal(body, &response) + assert.NoError(t, err, "Should parse JSON response") + + // Verify response structure + assert.Contains(t, response, "member_id", "Should have member_id") + assert.NotEmpty(t, response["member_id"], "Member ID should not be empty") + } + + t.Logf("Member create test %s: status=%d, body=%s", tc.name, resp.StatusCode, string(body)) + } + }) + } +} + +// TestMemberUpdate tests the PUT /user/teams/:team_id/members/:member_id endpoint +func TestMemberUpdate(t *testing.T) { + // Initialize test environment + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + // Get base URL from server config + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + // Register a test client for OAuth authentication + testClient := testutils.RegisterTestClient(t, "Member Update Test Client", []string{"https://localhost/callback"}) + defer testutils.CleanupTestClient(t, testClient.ClientID) + + // Obtain access token for authenticated requests + tokenInfo := testutils.ObtainAccessToken(t, serverURL, testClient.ClientID, testClient.ClientSecret, "https://localhost/callback", "openid profile") + + testCases := []struct { + name string + setupFunc func() (string, string) // Returns (teamID, memberID) + body map[string]interface{} + headers map[string]string + expectCode int + expectMsg string + }{ + { + "update member without authentication", + func() (string, string) { + team := createTestTeam(t, serverURL, baseURL, tokenInfo.AccessToken, "Update Test Team 1") + teamID := getTeamID(team) + memberID := createTestMember(t, serverURL, baseURL, teamID, tokenInfo.AccessToken, "test-update-user-1") + return teamID, memberID + }, + map[string]interface{}{ + "role_id": "admin", + }, + map[string]string{}, + 401, + "should require authentication", + }, + { + "update member role", + func() (string, string) { + team := createTestTeam(t, serverURL, baseURL, tokenInfo.AccessToken, "Update Test Team 2") + teamID := getTeamID(team) + memberID := createTestMember(t, serverURL, baseURL, teamID, tokenInfo.AccessToken, "test-update-user-2") + return teamID, memberID + }, + map[string]interface{}{ + "role_id": "admin", + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 200, + "should update member role", + }, + { + "update member status", + func() (string, string) { + team := createTestTeam(t, serverURL, baseURL, tokenInfo.AccessToken, "Update Test Team 3") + teamID := getTeamID(team) + memberID := createTestMember(t, serverURL, baseURL, teamID, tokenInfo.AccessToken, "test-update-user-3") + return teamID, memberID + }, + map[string]interface{}{ + "status": "inactive", + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 200, + "should update member status", + }, + { + "update non-existent member", + func() (string, string) { + team := createTestTeam(t, serverURL, baseURL, tokenInfo.AccessToken, "Update Test Team 5") + teamID := getTeamID(team) + return teamID, "999999" + }, + map[string]interface{}{ + "role_id": "admin", + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 404, + "should return not found for non-existent member", + }, + { + "update member in non-existent team", + func() (string, string) { + team := createTestTeam(t, serverURL, baseURL, tokenInfo.AccessToken, "Update Test Team 6") + teamID := getTeamID(team) + memberID := createTestMember(t, serverURL, baseURL, teamID, tokenInfo.AccessToken, "test-update-user-5") + return "non-existent-team-id", memberID + }, + map[string]interface{}{ + "role_id": "admin", + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 404, + "should return not found for non-existent team", + }, + { + "update member with invalid JSON", + func() (string, string) { + team := createTestTeam(t, serverURL, baseURL, tokenInfo.AccessToken, "Update Test Team 7") + teamID := getTeamID(team) + memberID := createTestMember(t, serverURL, baseURL, teamID, tokenInfo.AccessToken, "test-update-user-6") + return teamID, memberID + }, + nil, // Will send invalid JSON + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 400, + "should handle invalid JSON", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + teamID, memberID := tc.setupFunc() + requestURL := serverURL + baseURL + "/user/teams/" + teamID + "/members/" + memberID + + var req *http.Request + var err error + + if tc.body == nil { + // Send invalid JSON for invalid JSON test case + req, err = http.NewRequest("PUT", requestURL, bytes.NewBufferString("invalid json")) + } else { + bodyBytes, _ := json.Marshal(tc.body) + req, err = http.NewRequest("PUT", requestURL, bytes.NewBuffer(bodyBytes)) + } + assert.NoError(t, err, "Should create HTTP request") + + req.Header.Set("Content-Type", "application/json") + + // Add headers + for key, value := range tc.headers { + req.Header.Set(key, value) + } + + client := &http.Client{} + resp, err := client.Do(req) + assert.NoError(t, err, "HTTP request should succeed") + + if resp != nil { + defer resp.Body.Close() + assert.Equal(t, tc.expectCode, resp.StatusCode, "Expected status code %d for %s", tc.expectCode, tc.name) + + body, err := io.ReadAll(resp.Body) + assert.NoError(t, err, "Should read response body") + + if resp.StatusCode == 200 { + // Parse response as success message + var response map[string]interface{} + err = json.Unmarshal(body, &response) + assert.NoError(t, err, "Should parse JSON response") + + assert.Contains(t, response, "message", "Should have success message") + assert.Equal(t, "Member updated successfully", response["message"], "Should have correct success message") + } + + t.Logf("Member update test %s: status=%d, body=%s", tc.name, resp.StatusCode, string(body)) + } + }) + } +} + +// TestMemberDelete tests the DELETE /user/teams/:team_id/members/:member_id endpoint +func TestMemberDelete(t *testing.T) { + // Initialize test environment + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + // Get base URL from server config + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + // Register a test client for OAuth authentication + testClient := testutils.RegisterTestClient(t, "Member Delete Test Client", []string{"https://localhost/callback"}) + defer testutils.CleanupTestClient(t, testClient.ClientID) + + // Obtain access token for authenticated requests + tokenInfo := testutils.ObtainAccessToken(t, serverURL, testClient.ClientID, testClient.ClientSecret, "https://localhost/callback", "openid profile") + + // Create teams and members for testing deletion + createMemberForDeletion := func(name string) (string, string) { + team := createTestTeam(t, serverURL, baseURL, tokenInfo.AccessToken, "Delete Test Team "+name) + teamID := getTeamID(team) + memberID := createTestMember(t, serverURL, baseURL, teamID, tokenInfo.AccessToken, "test-delete-user-"+name) + return teamID, memberID + } + + testCases := []struct { + name string + setupFunc func() (string, string) // Returns (teamID, memberID) + headers map[string]string + expectCode int + expectMsg string + }{ + { + "delete member without authentication", + func() (string, string) { return createMemberForDeletion("1") }, + map[string]string{}, + 401, + "should require authentication", + }, + { + "delete existing member", + func() (string, string) { return createMemberForDeletion("2") }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 200, + "should delete member successfully", + }, + { + "delete non-existent member", + func() (string, string) { + team := createTestTeam(t, serverURL, baseURL, tokenInfo.AccessToken, "Delete Test Team 3") + return getTeamID(team), "999999" + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 404, + "should return not found for non-existent member", + }, + { + "delete member from non-existent team", + func() (string, string) { + _, memberID := createMemberForDeletion("4") + return "non-existent-team-id", memberID + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 404, + "should return not found for non-existent team", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + teamID, memberID := tc.setupFunc() + requestURL := serverURL + baseURL + "/user/teams/" + teamID + "/members/" + memberID + + req, err := http.NewRequest("DELETE", requestURL, nil) + assert.NoError(t, err, "Should create HTTP request") + + // Add headers + for key, value := range tc.headers { + req.Header.Set(key, value) + } + + client := &http.Client{} + resp, err := client.Do(req) + assert.NoError(t, err, "HTTP request should succeed") + + if resp != nil { + defer resp.Body.Close() + assert.Equal(t, tc.expectCode, resp.StatusCode, "Expected status code %d for %s", tc.expectCode, tc.name) + + body, err := io.ReadAll(resp.Body) + assert.NoError(t, err, "Should read response body") + + if resp.StatusCode == 200 { + // Parse response as success message + var response map[string]interface{} + err = json.Unmarshal(body, &response) + assert.NoError(t, err, "Should parse JSON response") + + assert.Contains(t, response, "message", "Should have success message") + assert.Equal(t, "Member removed successfully", response["message"], "Should have correct success message") + } + + t.Logf("Member delete test %s: status=%d, body=%s", tc.name, resp.StatusCode, string(body)) + } + }) + } +} + +// TestMemberPermissionVerification tests permission verification for member operations +func TestMemberPermissionVerification(t *testing.T) { + // Initialize test environment + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + // Get base URL from server config + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + // Register test clients for different users + ownerClient := testutils.RegisterTestClient(t, "Owner Test Client", []string{"https://localhost/callback"}) + defer testutils.CleanupTestClient(t, ownerClient.ClientID) + + nonOwnerClient := testutils.RegisterTestClient(t, "Non-Owner Test Client", []string{"https://localhost/callback"}) + defer testutils.CleanupTestClient(t, nonOwnerClient.ClientID) + + // Obtain access tokens + ownerToken := testutils.ObtainAccessToken(t, serverURL, ownerClient.ClientID, ownerClient.ClientSecret, "https://localhost/callback", "openid profile") + nonOwnerToken := testutils.ObtainAccessToken(t, serverURL, nonOwnerClient.ClientID, nonOwnerClient.ClientSecret, "https://localhost/callback", "openid profile") + + // Create a team with owner + createdTeam := createTestTeam(t, serverURL, baseURL, ownerToken.AccessToken, "Permission Test Team") + teamID := getTeamID(createdTeam) + + // Add non-owner as a member + memberID := createTestMember(t, serverURL, baseURL, teamID, ownerToken.AccessToken, nonOwnerToken.UserID) + + testCases := []struct { + name string + endpoint string + method string + token string + expectCode int + expectMsg string + }{ + { + "owner can list members", + "/user/teams/" + teamID + "/members", + "GET", + ownerToken.AccessToken, + 200, + "owner should be able to list members", + }, + { + "member can list members", + "/user/teams/" + teamID + "/members", + "GET", + nonOwnerToken.AccessToken, + 200, + "member should be able to list members", + }, + { + "owner can get member details", + "/user/teams/" + teamID + "/members/" + memberID, + "GET", + ownerToken.AccessToken, + 200, + "owner should be able to get member details", + }, + { + "member can get member details", + "/user/teams/" + teamID + "/members/" + memberID, + "GET", + nonOwnerToken.AccessToken, + 200, + "member should be able to get member details", + }, + { + "owner can create members", + "/user/teams/" + teamID + "/members/direct", + "POST", + ownerToken.AccessToken, + 201, // Will create successfully + "owner should be able to create members", + }, + { + "member cannot create members", + "/user/teams/" + teamID + "/members/direct", + "POST", + nonOwnerToken.AccessToken, + 403, + "member should not be able to create members", + }, + { + "owner can update members", + "/user/teams/" + teamID + "/members/" + memberID, + "PUT", + ownerToken.AccessToken, + 200, + "owner should be able to update members", + }, + { + "member cannot update members", + "/user/teams/" + teamID + "/members/" + memberID, + "PUT", + nonOwnerToken.AccessToken, + 403, + "member should not be able to update members", + }, + { + "owner can delete members", + "/user/teams/" + teamID + "/members/" + memberID, + "DELETE", + ownerToken.AccessToken, + 200, + "owner should be able to delete members", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + requestURL := serverURL + baseURL + tc.endpoint + + var req *http.Request + var err error + + // Create request body for POST/PUT methods + if tc.method == "POST" { + body := map[string]interface{}{ + "user_id": "test-permission-user", + "role_id": "member", + } + bodyBytes, _ := json.Marshal(body) + req, err = http.NewRequest(tc.method, requestURL, bytes.NewBuffer(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + } else if tc.method == "PUT" { + body := map[string]interface{}{ + "role_id": "admin", + } + bodyBytes, _ := json.Marshal(body) + req, err = http.NewRequest(tc.method, requestURL, bytes.NewBuffer(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + } else { + req, err = http.NewRequest(tc.method, requestURL, nil) + } + + assert.NoError(t, err, "Should create HTTP request") + + req.Header.Set("Authorization", "Bearer "+tc.token) + + client := &http.Client{} + resp, err := client.Do(req) + assert.NoError(t, err, "HTTP request should succeed") + + if resp != nil { + defer resp.Body.Close() + assert.Equal(t, tc.expectCode, resp.StatusCode, "Expected status code %d for %s", tc.expectCode, tc.name) + + body, err := io.ReadAll(resp.Body) + assert.NoError(t, err, "Should read response body") + + t.Logf("Permission test %s: status=%d, body=%s", tc.name, resp.StatusCode, string(body)) + } + }) + } +} + +// Helper functions + +// createTestTeam creates a team for testing and returns the team data +func createTestTeam(t *testing.T, serverURL, baseURL, accessToken, teamName string) map[string]interface{} { + createTeamBody := map[string]interface{}{ + "name": teamName, + "description": "Team created for testing purposes", + } + + bodyBytes, err := json.Marshal(createTeamBody) + assert.NoError(t, err, "Should marshal team creation body") + + req, err := http.NewRequest("POST", serverURL+baseURL+"/user/teams", bytes.NewBuffer(bodyBytes)) + assert.NoError(t, err, "Should create team creation request") + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+accessToken) + + client := &http.Client{} + resp, err := client.Do(req) + assert.NoError(t, err, "Should send team creation request") + defer resp.Body.Close() + + assert.Equal(t, 201, resp.StatusCode, "Should create team successfully") + + body, err := io.ReadAll(resp.Body) + assert.NoError(t, err, "Should read team creation response") + + var team map[string]interface{} + err = json.Unmarshal(body, &team) + assert.NoError(t, err, "Should parse team creation response") + + return team +} + +// createTestMember creates a member for testing and returns the user_id (which serves as member_id in API context) +func createTestMember(t *testing.T, serverURL, baseURL, teamID, accessToken, userID string) string { + createMemberBody := map[string]interface{}{ + "user_id": userID, + "member_type": "user", + "role_id": "member", + } + + bodyBytes, err := json.Marshal(createMemberBody) + assert.NoError(t, err, "Should marshal member creation body") + + req, err := http.NewRequest("POST", serverURL+baseURL+"/user/teams/"+teamID+"/members/direct", bytes.NewBuffer(bodyBytes)) + assert.NoError(t, err, "Should create member creation request") + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+accessToken) + + client := &http.Client{} + resp, err := client.Do(req) + assert.NoError(t, err, "Should send member creation request") + defer resp.Body.Close() + + assert.Equal(t, 201, resp.StatusCode, "Should create member successfully") + + body, err := io.ReadAll(resp.Body) + assert.NoError(t, err, "Should read member creation response") + + var response map[string]interface{} + err = json.Unmarshal(body, &response) + assert.NoError(t, err, "Should parse member creation response") + + _, ok := response["member_id"] + assert.True(t, ok, "Should have member_id in response") + + // For API purposes, the member_id is the user_id in the context of team_id + // So we return the user_id that was used to create the member + return userID +} + +// getOwnerMemberID gets the user_id of the team owner (which serves as member_id in API context) +func getOwnerMemberID(t *testing.T, serverURL, baseURL, teamID, accessToken string) string { + req, err := http.NewRequest("GET", serverURL+baseURL+"/user/teams/"+teamID+"/members", nil) + assert.NoError(t, err, "Should create member list request") + + req.Header.Set("Authorization", "Bearer "+accessToken) + + client := &http.Client{} + resp, err := client.Do(req) + assert.NoError(t, err, "Should send member list request") + defer resp.Body.Close() + + assert.Equal(t, 200, resp.StatusCode, "Should get members successfully") + + body, err := io.ReadAll(resp.Body) + assert.NoError(t, err, "Should read member list response") + + var response map[string]interface{} + err = json.Unmarshal(body, &response) + assert.NoError(t, err, "Should parse member list response") + + data, ok := response["data"].([]interface{}) + assert.True(t, ok, "Should have data array") + assert.Greater(t, len(data), 0, "Should have at least one member") + + // Find the owner member and return their user_id + for _, item := range data { + member := item.(map[string]interface{}) + if role, ok := member["role_id"].(string); ok && role == "owner" { + userID, ok := member["user_id"].(string) + if !ok { + t.Fatal("Owner member missing user_id") + } + return userID + } + } + + t.Fatal("Could not find owner member") + return "" +} + +// Note: getTeamID function is already defined in team_test.go diff --git a/openapi/user/member.go b/openapi/user/member.go new file mode 100644 index 00000000..74b04c01 --- /dev/null +++ b/openapi/user/member.go @@ -0,0 +1,822 @@ +package user + +import ( + "context" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/yaoapp/gou/model" + "github.com/yaoapp/gou/process" + "github.com/yaoapp/kun/exception" + "github.com/yaoapp/kun/log" + "github.com/yaoapp/kun/maps" + "github.com/yaoapp/yao/openapi/oauth" + "github.com/yaoapp/yao/openapi/response" +) + +// Member Management Handlers + +// GinMemberList handles GET /teams/:team_id/members - Get team members +func GinMemberList(c *gin.Context) { + // Get authorized user info + authInfo := oauth.GetAuthorizedInfo(c) + if authInfo == nil || authInfo.UserID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidClient.Code, + ErrorDescription: "User not authenticated", + } + response.RespondWithError(c, response.StatusUnauthorized, errorResp) + return + } + + teamID := c.Param("team_id") + if teamID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Team ID is required", + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + // Parse pagination parameters + page := 1 + pagesize := 20 + + if p := c.Query("page"); p != "" { + if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 { + page = parsed + } + } + + if ps := c.Query("pagesize"); ps != "" { + if parsed, err := strconv.Atoi(ps); err == nil && parsed > 0 && parsed <= 100 { + pagesize = parsed + } + } + + // Call business logic + result, err := memberList(c.Request.Context(), authInfo.UserID, teamID, page, pagesize, c.Query("status")) + if err != nil { + log.Error("Failed to get team members: %v", err) + // Check error type for appropriate response + if strings.Contains(err.Error(), "not found") { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Team not found", + } + response.RespondWithError(c, response.StatusNotFound, errorResp) + } else if strings.Contains(err.Error(), "access denied") { + errorResp := &response.ErrorResponse{ + Code: response.ErrAccessDenied.Code, + ErrorDescription: err.Error(), + } + response.RespondWithError(c, response.StatusForbidden, errorResp) + } else { + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to retrieve team members", + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + } + return + } + + // Return the paginated result + c.JSON(http.StatusOK, result) +} + +// GinMemberGet handles GET /teams/:team_id/members/:member_id - Get team member details +func GinMemberGet(c *gin.Context) { + // Get authorized user info + authInfo := oauth.GetAuthorizedInfo(c) + if authInfo == nil || authInfo.UserID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidClient.Code, + ErrorDescription: "User not authenticated", + } + response.RespondWithError(c, response.StatusUnauthorized, errorResp) + return + } + + teamID := c.Param("team_id") + memberID := c.Param("member_id") + if teamID == "" || memberID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Team ID and Member ID are required", + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + // Call business logic + memberData, err := memberGet(c.Request.Context(), authInfo.UserID, teamID, memberID) + if err != nil { + log.Error("Failed to get member details: %v", err) + // Check error type for appropriate response + if strings.Contains(err.Error(), "not found") { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Member not found", + } + response.RespondWithError(c, response.StatusNotFound, errorResp) + } else if strings.Contains(err.Error(), "access denied") { + errorResp := &response.ErrorResponse{ + Code: response.ErrAccessDenied.Code, + ErrorDescription: err.Error(), + } + response.RespondWithError(c, response.StatusForbidden, errorResp) + } else { + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to retrieve member details", + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + } + return + } + + // Convert to response format + member := mapToMemberDetailResponse(memberData) + c.JSON(http.StatusOK, member) +} + +// GinMemberCreateDirect handles POST /teams/:team_id/members/direct - Add member directly +func GinMemberCreateDirect(c *gin.Context) { + // Get authorized user info + authInfo := oauth.GetAuthorizedInfo(c) + if authInfo == nil || authInfo.UserID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidClient.Code, + ErrorDescription: "User not authenticated", + } + response.RespondWithError(c, response.StatusUnauthorized, errorResp) + return + } + + teamID := c.Param("team_id") + if teamID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Team ID is required", + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + // Parse request body + var req CreateMemberRequest + if err := c.ShouldBindJSON(&req); err != nil { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Invalid request body: " + err.Error(), + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + // Prepare member data + memberData := maps.MapStrAny{ + "user_id": req.UserID, + "member_type": req.MemberType, + "role_id": req.RoleID, + } + + // Add settings if provided + if req.Settings != nil { + memberData["settings"] = req.Settings + } + + // Call business logic + memberID, err := memberCreateDirect(c.Request.Context(), authInfo.UserID, teamID, memberData) + if err != nil { + log.Error("Failed to create member: %v", err) + // Check error type for appropriate response + if strings.Contains(err.Error(), "not found") { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Team not found", + } + response.RespondWithError(c, response.StatusNotFound, errorResp) + } else if strings.Contains(err.Error(), "access denied") { + errorResp := &response.ErrorResponse{ + Code: response.ErrAccessDenied.Code, + ErrorDescription: err.Error(), + } + response.RespondWithError(c, response.StatusForbidden, errorResp) + } else if strings.Contains(err.Error(), "already exists") { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: err.Error(), + } + response.RespondWithError(c, response.StatusConflict, errorResp) + } else { + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to create member", + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + } + return + } + + // Return created member ID + c.JSON(http.StatusCreated, gin.H{"member_id": memberID}) +} + +// GinMemberUpdate handles PUT /teams/:team_id/members/:member_id - Update team member +func GinMemberUpdate(c *gin.Context) { + // Get authorized user info + authInfo := oauth.GetAuthorizedInfo(c) + if authInfo == nil || authInfo.UserID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidClient.Code, + ErrorDescription: "User not authenticated", + } + response.RespondWithError(c, response.StatusUnauthorized, errorResp) + return + } + + teamID := c.Param("team_id") + memberID := c.Param("member_id") + if teamID == "" || memberID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Team ID and Member ID are required", + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + // Parse request body + var req UpdateMemberRequest + if err := c.ShouldBindJSON(&req); err != nil { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Invalid request body: " + err.Error(), + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + // Prepare update data + updateData := maps.MapStrAny{} + + if req.RoleID != "" { + updateData["role_id"] = req.RoleID + } + if req.Status != "" { + updateData["status"] = req.Status + } + if req.Settings != nil { + updateData["settings"] = req.Settings + } + if req.LastActivity != "" { + updateData["last_activity"] = req.LastActivity + } + + // Call business logic + err := memberUpdate(c.Request.Context(), authInfo.UserID, teamID, memberID, updateData) + if err != nil { + log.Error("Failed to update member: %v", err) + // Check error type for appropriate response + if strings.Contains(err.Error(), "not found") { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Member not found", + } + response.RespondWithError(c, response.StatusNotFound, errorResp) + } else if strings.Contains(err.Error(), "access denied") { + errorResp := &response.ErrorResponse{ + Code: response.ErrAccessDenied.Code, + ErrorDescription: err.Error(), + } + response.RespondWithError(c, response.StatusForbidden, errorResp) + } else { + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to update member", + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + } + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Member updated successfully"}) +} + +// GinMemberDelete handles DELETE /teams/:team_id/members/:member_id - Remove team member +func GinMemberDelete(c *gin.Context) { + // Get authorized user info + authInfo := oauth.GetAuthorizedInfo(c) + if authInfo == nil || authInfo.UserID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidClient.Code, + ErrorDescription: "User not authenticated", + } + response.RespondWithError(c, response.StatusUnauthorized, errorResp) + return + } + + teamID := c.Param("team_id") + memberID := c.Param("member_id") + if teamID == "" || memberID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Team ID and Member ID are required", + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + // Call business logic + err := memberDelete(c.Request.Context(), authInfo.UserID, teamID, memberID) + if err != nil { + log.Error("Failed to delete member: %v", err) + // Check error type for appropriate response + if strings.Contains(err.Error(), "not found") { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Member not found", + } + response.RespondWithError(c, response.StatusNotFound, errorResp) + } else if strings.Contains(err.Error(), "access denied") { + errorResp := &response.ErrorResponse{ + Code: response.ErrAccessDenied.Code, + ErrorDescription: err.Error(), + } + response.RespondWithError(c, response.StatusForbidden, errorResp) + } else { + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to delete member", + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + } + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Member removed successfully"}) +} + +// Yao Process Handlers (for Yao application calls) + +// ProcessMemberList user.member.list Member list processor +// Args[0] string: team_id +// Args[1] map: Query parameters {"status": "active", "page": 1, "pagesize": 20} +// Return: map: Paginated member list +func ProcessMemberList(process *process.Process) interface{} { + process.ValidateArgNums(2) + + // Get user_id from session + userIDStr := GetUserIDFromSession(process) + + teamID := process.ArgsString(0) + if teamID == "" { + exception.New("team_id is required", 400).Throw() + } + + // Parse query parameters + queryMap := process.ArgsMap(1) + + // Parse pagination + page := 1 + pagesize := 20 + + if p, ok := queryMap["page"]; ok { + if pageInt, ok := p.(int); ok && pageInt > 0 { + page = pageInt + } + } + + if ps, ok := queryMap["pagesize"]; ok { + if pagesizeInt, ok := ps.(int); ok && pagesizeInt > 0 && pagesizeInt <= 100 { + pagesize = pagesizeInt + } + } + + // Get status filter + status := "" + if s, ok := queryMap["status"].(string); ok { + status = s + } + + // Get context + ctx := process.Context + if ctx == nil { + ctx = context.Background() + } + + // Call business logic + result, err := memberList(ctx, userIDStr, teamID, page, pagesize, status) + if err != nil { + exception.New("failed to list members: %s", 500, err.Error()).Throw() + } + + return result +} + +// ProcessMemberGet user.member.get Member get processor +// Args[0] string: team_id +// Args[1] string: member_id +// Return: map: Member details +func ProcessMemberGet(process *process.Process) interface{} { + process.ValidateArgNums(2) + + // Get user_id from session + userIDStr := GetUserIDFromSession(process) + + teamID := process.ArgsString(0) + memberID := process.ArgsString(1) + + if teamID == "" || memberID == "" { + exception.New("team_id and member_id are required", 400).Throw() + } + + // Get context + ctx := process.Context + if ctx == nil { + ctx = context.Background() + } + + // Call business logic + result, err := memberGet(ctx, userIDStr, teamID, memberID) + if err != nil { + exception.New("failed to get member: %s", 500, err.Error()).Throw() + } + + return result +} + +// ProcessMemberCreateDirect user.member.create Member create processor +// Args[0] string: team_id +// Args[1] map: Member data {"user_id": "user123", "member_type": "user", "role_id": "member", "settings": {...}} +// Return: map: {"member_id": "created_member_id"} +func ProcessMemberCreateDirect(process *process.Process) interface{} { + process.ValidateArgNums(2) + + // Get user_id from session + userIDStr := GetUserIDFromSession(process) + + teamID := process.ArgsString(0) + memberData := maps.MapStrAny(process.ArgsMap(1)) + + if teamID == "" { + exception.New("team_id is required", 400).Throw() + } + + // Validate required fields + if _, ok := memberData["user_id"]; !ok { + exception.New("user_id is required", 400).Throw() + } + if _, ok := memberData["role_id"]; !ok { + exception.New("role_id is required", 400).Throw() + } + + // Get context + ctx := process.Context + if ctx == nil { + ctx = context.Background() + } + + // Call business logic + memberID, err := memberCreateDirect(ctx, userIDStr, teamID, memberData) + if err != nil { + exception.New("failed to create member: %s", 500, err.Error()).Throw() + } + + return map[string]interface{}{ + "member_id": memberID, + } +} + +// ProcessMemberUpdate user.member.update Member update processor +// Args[0] string: team_id +// Args[1] string: member_id +// Args[2] map: Update data {"role_id": "admin", "status": "active", "settings": {...}} +// Return: map: {"message": "success"} +func ProcessMemberUpdate(process *process.Process) interface{} { + process.ValidateArgNums(3) + + // Get user_id from session + userIDStr := GetUserIDFromSession(process) + + teamID := process.ArgsString(0) + memberID := process.ArgsString(1) + updateData := maps.MapStrAny(process.ArgsMap(2)) + + if teamID == "" || memberID == "" { + exception.New("team_id and member_id are required", 400).Throw() + } + + // Get context + ctx := process.Context + if ctx == nil { + ctx = context.Background() + } + + // Call business logic + err := memberUpdate(ctx, userIDStr, teamID, memberID, updateData) + if err != nil { + exception.New("failed to update member: %s", 500, err.Error()).Throw() + } + + return map[string]interface{}{ + "message": "success", + } +} + +// ProcessMemberDelete user.member.delete Member delete processor +// Args[0] string: team_id +// Args[1] string: member_id +// Return: map: {"message": "success"} +func ProcessMemberDelete(process *process.Process) interface{} { + process.ValidateArgNums(2) + + // Get user_id from session + userIDStr := GetUserIDFromSession(process) + + teamID := process.ArgsString(0) + memberID := process.ArgsString(1) + + if teamID == "" || memberID == "" { + exception.New("team_id and member_id are required", 400).Throw() + } + + // Get context + ctx := process.Context + if ctx == nil { + ctx = context.Background() + } + + // Call business logic + err := memberDelete(ctx, userIDStr, teamID, memberID) + if err != nil { + exception.New("failed to delete member: %s", 500, err.Error()).Throw() + } + + return map[string]interface{}{ + "message": "success", + } +} + +// Private Business Logic Functions (internal use only) + +// memberList handles the business logic for listing team members +func memberList(ctx context.Context, userID, teamID string, page, pagesize int, status string) (maps.MapStr, error) { + // Check if user has access to the team (read permission: owner or member) + isOwner, isMember, err := checkTeamAccess(ctx, userID, teamID) + if err != nil { + return nil, err + } + + // Allow access if user is owner or member + if !isOwner && !isMember { + return nil, fmt.Errorf("access denied: user is not a member of this team") + } + + // Get user provider instance + provider, err := getUserProvider() + if err != nil { + return nil, fmt.Errorf("failed to get user provider: %w", err) + } + + // Build query parameters + param := model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "team_id", Value: teamID}, + }, + Orders: []model.QueryOrder{ + {Column: "joined_at", Option: "desc"}, + {Column: "created_at", Option: "desc"}, + }, + } + + // Add status filter if provided + if status != "" { + param.Wheres = append(param.Wheres, model.QueryWhere{ + Column: "status", + Value: status, + }) + } + + // Get paginated members + result, err := provider.PaginateMembers(ctx, param, page, pagesize) + if err != nil { + return nil, fmt.Errorf("failed to retrieve members: %w", err) + } + + return result, nil +} + +// memberGet handles the business logic for getting a specific team member +func memberGet(ctx context.Context, userID, teamID, memberID string) (maps.MapStrAny, error) { + // Check if user has access to the team (read permission: owner or member) + isOwner, isMember, err := checkTeamAccess(ctx, userID, teamID) + if err != nil { + return nil, err + } + + // Allow access if user is owner or member + if !isOwner && !isMember { + return nil, fmt.Errorf("access denied: user is not a member of this team") + } + + // Get user provider instance + provider, err := getUserProvider() + if err != nil { + return nil, fmt.Errorf("failed to get user provider: %w", err) + } + + // Get member details using team_id + user_id (business keys) + // memberID parameter is actually user_id in the context of team_id + memberData, err := provider.GetMember(ctx, teamID, memberID) + if err != nil { + return nil, fmt.Errorf("member not found: %w", err) + } + + return memberData, nil +} + +// memberCreateDirect handles the business logic for creating a team member directly +func memberCreateDirect(ctx context.Context, userID, teamID string, memberData maps.MapStrAny) (int64, error) { + // Check if user has access to the team (write permission: owner only) + isOwner, _, err := checkTeamAccess(ctx, userID, teamID) + if err != nil { + return 0, err + } + + // Only allow access if user is owner + if !isOwner { + return 0, fmt.Errorf("access denied: only team owner can add members") + } + + // Get user provider instance + provider, err := getUserProvider() + if err != nil { + return 0, fmt.Errorf("failed to get user provider: %w", err) + } + + // Check if member already exists + memberUserID := toString(memberData["user_id"]) + exists, err := provider.MemberExists(ctx, teamID, memberUserID) + if err != nil { + return 0, fmt.Errorf("failed to check member existence: %w", err) + } + if exists { + return 0, fmt.Errorf("member already exists in this team") + } + + // Set team ID and default values + memberData["team_id"] = teamID + if memberData["member_type"] == nil || memberData["member_type"] == "" { + memberData["member_type"] = "user" + } + memberData["status"] = "active" + memberData["joined_at"] = time.Now() + memberData["created_at"] = time.Now() + memberData["updated_at"] = time.Now() + + // Create member + memberID, err := provider.CreateMember(ctx, memberData) + if err != nil { + return 0, fmt.Errorf("failed to create member: %w", err) + } + + return memberID, nil +} + +// memberUpdate handles the business logic for updating a team member +func memberUpdate(ctx context.Context, userID, teamID, memberUserID string, updateData maps.MapStrAny) error { + // Check if user has access to the team (write permission: owner only) + isOwner, _, err := checkTeamAccess(ctx, userID, teamID) + if err != nil { + return err + } + + // Only allow access if user is owner + if !isOwner { + return fmt.Errorf("access denied: only team owner can update members") + } + + // Get user provider instance + provider, err := getUserProvider() + if err != nil { + return fmt.Errorf("failed to get user provider: %w", err) + } + + // Check if member exists using team_id + user_id (business keys) + _, err = provider.GetMember(ctx, teamID, memberUserID) + if err != nil { + return fmt.Errorf("member not found: %w", err) + } + + // Add updated_at timestamp + updateData["updated_at"] = time.Now() + + // Update member using team_id + user_id + err = provider.UpdateMember(ctx, teamID, memberUserID, updateData) + if err != nil { + return fmt.Errorf("failed to update member: %w", err) + } + + return nil +} + +// memberDelete handles the business logic for deleting a team member +func memberDelete(ctx context.Context, userID, teamID, memberID string) error { + // Check if user has access to the team (write permission: owner only) + isOwner, _, err := checkTeamAccess(ctx, userID, teamID) + if err != nil { + return err + } + + // Only allow access if user is owner + if !isOwner { + return fmt.Errorf("access denied: only team owner can remove members") + } + + // Get user provider instance + provider, err := getUserProvider() + if err != nil { + return fmt.Errorf("failed to get user provider: %w", err) + } + + // Check if member exists using team_id + user_id (business keys) + // memberID parameter is actually user_id in the context of team_id + _, err = provider.GetMember(ctx, teamID, memberID) + if err != nil { + return fmt.Errorf("member not found: %w", err) + } + + // Remove member using team_id + user_id + err = provider.RemoveMember(ctx, teamID, memberID) + if err != nil { + return fmt.Errorf("failed to delete member: %w", err) + } + + return nil +} + +// Private Helper Functions (internal use only) + +// checkTeamAccess checks if user has access to the team +// Returns: (isOwner bool, isMember bool, error) +func checkTeamAccess(ctx context.Context, userID, teamID 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 + return provider.CheckTeamAccess(ctx, teamID, userID) +} + +// mapToMemberResponse converts a map to MemberResponse +func mapToMemberResponse(data maps.MapStr) MemberResponse { + member := MemberResponse{ + ID: toInt64(data["id"]), + TeamID: toString(data["team_id"]), + UserID: toString(data["user_id"]), + MemberType: toString(data["member_type"]), + RoleID: toString(data["role_id"]), + Status: toString(data["status"]), + InvitedBy: toString(data["invited_by"]), + InvitedAt: toTimeString(data["invited_at"]), + JoinedAt: toTimeString(data["joined_at"]), + LastActivity: toTimeString(data["last_activity"]), + CreatedAt: toTimeString(data["created_at"]), + UpdatedAt: toTimeString(data["updated_at"]), + } + + // Add settings if available + if settings, ok := data["settings"]; ok { + if settingsMap, ok := settings.(map[string]interface{}); ok { + member.Settings = settingsMap + } + } + + return member +} + +// mapToMemberDetailResponse converts a map to MemberDetailResponse +func mapToMemberDetailResponse(data maps.MapStr) MemberDetailResponse { + member := MemberDetailResponse{ + MemberResponse: mapToMemberResponse(data), + } + + // Add user info if available (could be joined from user table) + if userInfo, ok := data["user_info"]; ok { + if userInfoMap, ok := userInfo.(map[string]interface{}); ok { + member.UserInfo = userInfoMap + } + } + + return member +} diff --git a/openapi/user/types.go b/openapi/user/types.go index 1d6c25a9..baf3e121 100644 --- a/openapi/user/types.go +++ b/openapi/user/types.go @@ -238,3 +238,45 @@ type UpdateTeamRequest struct { Description string `json:"description,omitempty"` Settings map[string]interface{} `json:"settings,omitempty"` } + +// ==== Member API Types ==== + +// MemberResponse represents a team member in API responses +type MemberResponse struct { + ID int64 `json:"id"` + TeamID string `json:"team_id"` + UserID string `json:"user_id"` + MemberType string `json:"member_type"` + RoleID string `json:"role_id"` + Status string `json:"status"` + InvitedBy string `json:"invited_by,omitempty"` + InvitedAt string `json:"invited_at,omitempty"` + JoinedAt string `json:"joined_at,omitempty"` + LastActivity string `json:"last_activity,omitempty"` + Settings map[string]interface{} `json:"settings,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// MemberDetailResponse represents detailed member information +type MemberDetailResponse struct { + MemberResponse + // Add additional fields that are only included in detailed responses + UserInfo map[string]interface{} `json:"user_info,omitempty"` +} + +// CreateMemberRequest represents the request to add a member directly +type CreateMemberRequest struct { + UserID string `json:"user_id" binding:"required"` + MemberType string `json:"member_type,omitempty"` // "user" or "robot" + RoleID string `json:"role_id" binding:"required"` + Settings map[string]interface{} `json:"settings,omitempty"` +} + +// UpdateMemberRequest represents the request to update a member +type UpdateMemberRequest struct { + RoleID string `json:"role_id,omitempty"` + Status string `json:"status,omitempty"` + Settings map[string]interface{} `json:"settings,omitempty"` + LastActivity string `json:"last_activity,omitempty"` +} diff --git a/openapi/user/user.go b/openapi/user/user.go index 831d25b4..8f0ab0c5 100644 --- a/openapi/user/user.go +++ b/openapi/user/user.go @@ -62,11 +62,11 @@ func attachTeam(group *gin.RouterGroup, oauth types.OAuth) { team.DELETE("/:team_id", GinTeamDelete) // Delete user team // Member Management - team.GET("/:team_id/members", placeholder) // Get user team members - team.GET("/:team_id/members/:member_id", placeholder) // Get user team member details - team.POST("/:team_id/members/direct", placeholder) // Add member directly (for bots/system) - team.PUT("/:team_id/members/:member_id", placeholder) // Update user team member - team.DELETE("/:team_id/members/:member_id", placeholder) // Remove user team member + team.GET("/:team_id/members", GinMemberList) // Get user team members + team.GET("/:team_id/members/:member_id", GinMemberGet) // Get user team member details + team.POST("/:team_id/members/direct", GinMemberCreateDirect) // Add member directly (for bots/system) + team.PUT("/:team_id/members/:member_id", GinMemberUpdate) // Update user team member + team.DELETE("/:team_id/members/:member_id", GinMemberDelete) // Remove user team member // Member Invitation Management team.POST("/:team_id/invitations", placeholder) // Send team invitation