diff --git a/openapi/user/member.go b/openapi/user/member.go index f4afdd3f..100e6979 100644 --- a/openapi/user/member.go +++ b/openapi/user/member.go @@ -87,7 +87,7 @@ func GinMemberList(c *gin.Context) { } // Return the paginated result - c.JSON(http.StatusOK, result) + response.RespondWithSuccess(c, http.StatusOK, result) } // GinMemberGet handles GET /teams/:team_id/members/:member_id - Get team member details @@ -143,7 +143,7 @@ func GinMemberGet(c *gin.Context) { // Convert to response format member := mapToMemberDetailResponse(memberData) - c.JSON(http.StatusOK, member) + response.RespondWithSuccess(c, http.StatusOK, member) } // GinMemberCreateDirect handles POST /teams/:team_id/members - Add member directly to team @@ -226,7 +226,7 @@ func GinMemberCreateDirect(c *gin.Context) { } // Return created member ID - c.JSON(http.StatusCreated, gin.H{"member_id": memberID}) + response.RespondWithSuccess(c, http.StatusCreated, gin.H{"member_id": memberID}) } // GinMemberUpdate handles PUT /teams/:team_id/members/:member_id - Update team member @@ -307,7 +307,7 @@ func GinMemberUpdate(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{"message": "Member updated successfully"}) + response.RespondWithSuccess(c, http.StatusOK, gin.H{"message": "Member updated successfully"}) } // GinMemberDelete handles DELETE /teams/:team_id/members/:member_id - Remove team member @@ -361,7 +361,7 @@ func GinMemberDelete(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{"message": "Member removed successfully"}) + response.RespondWithSuccess(c, http.StatusOK, gin.H{"message": "Member removed successfully"}) } // Yao Process Handlers (for Yao application calls) diff --git a/openapi/user/team.go b/openapi/user/team.go index 9d371c4f..19a23119 100644 --- a/openapi/user/team.go +++ b/openapi/user/team.go @@ -21,7 +21,6 @@ import ( // Team Management Handlers -// Public Team Configuration Endpoint // GinTeamConfig handles GET /teams/config - Get team configuration (public) func GinTeamConfig(c *gin.Context) { locale := c.Query("locale") @@ -35,11 +34,15 @@ func GinTeamConfig(c *gin.Context) { config := GetTeamConfig(locale) if config == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Team configuration not found"}) + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Team configuration not found", + } + response.RespondWithError(c, response.StatusNotFound, errorResp) return } - c.JSON(http.StatusOK, config) + response.RespondWithSuccess(c, http.StatusOK, config) } // GinTeamList handles GET /teams - Get user teams @@ -123,7 +126,7 @@ func GinTeamList(c *gin.Context) { } // Return the paginated result directly (consistent with other modules) - c.JSON(http.StatusOK, result) + response.RespondWithSuccess(c, http.StatusOK, result) } // GinTeamGet handles GET /teams/:id - Get user team details @@ -195,7 +198,7 @@ func GinTeamGet(c *gin.Context) { // Convert to response format team := mapToTeamDetailResponse(teamData) - c.JSON(http.StatusOK, team) + response.RespondWithSuccess(c, http.StatusOK, team) } // GinTeamCreate handles POST /teams - Create user team @@ -250,7 +253,7 @@ func GinTeamCreate(c *gin.Context) { if err != nil { log.Error("Failed to get user provider: %v", err) // Return basic response if we can't get details - c.JSON(http.StatusCreated, gin.H{"team_id": teamID}) + response.RespondWithSuccess(c, http.StatusCreated, gin.H{"team_id": teamID}) return } @@ -258,13 +261,13 @@ func GinTeamCreate(c *gin.Context) { if err != nil { log.Error("Failed to get created team details: %v", err) // Return basic response if we can't get details - c.JSON(http.StatusCreated, gin.H{"team_id": teamID}) + response.RespondWithSuccess(c, http.StatusCreated, gin.H{"team_id": teamID}) return } // Convert to response format team := mapToTeamDetailResponse(createdTeam) - c.JSON(http.StatusCreated, team) + response.RespondWithSuccess(c, http.StatusCreated, team) } // GinTeamUpdate handles PUT /teams/:id - Update user team @@ -345,20 +348,20 @@ func GinTeamUpdate(c *gin.Context) { provider, err := getUserProvider() if err != nil { log.Error("Failed to get user provider: %v", err) - c.JSON(http.StatusOK, gin.H{"message": "Team updated successfully"}) + response.RespondWithSuccess(c, http.StatusOK, gin.H{"message": "Team updated successfully"}) return } updatedTeam, err := provider.GetTeamDetail(c.Request.Context(), teamID) if err != nil { log.Error("Failed to get updated team details: %v", err) - c.JSON(http.StatusOK, gin.H{"message": "Team updated successfully"}) + response.RespondWithSuccess(c, http.StatusOK, gin.H{"message": "Team updated successfully"}) return } // Convert to response format team := mapToTeamDetailResponse(updatedTeam) - c.JSON(http.StatusOK, team) + response.RespondWithSuccess(c, http.StatusOK, team) } // GinTeamDelete handles DELETE /teams/:id - Delete user team @@ -411,7 +414,7 @@ func GinTeamDelete(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{"message": "Team deleted successfully"}) + response.RespondWithSuccess(c, http.StatusOK, gin.H{"message": "Team deleted successfully"}) } // Yao Process Handlers (for Yao application calls) diff --git a/openapi/user/invitation.go b/openapi/user/team_invitation.go similarity index 75% rename from openapi/user/invitation.go rename to openapi/user/team_invitation.go index 2fc2f7bf..0a91a61c 100644 --- a/openapi/user/invitation.go +++ b/openapi/user/team_invitation.go @@ -23,10 +23,10 @@ import ( "github.com/yaoapp/yao/share" ) -// Team Invitation Management Handlers +// User Team Invitation Management Handlers -// GinInvitationList handles GET /teams/:team_id/invitations - Get team invitations -func GinInvitationList(c *gin.Context) { +// GinTeamInvitationList handles GET /teams/:team_id/invitations - Get team invitations +func GinTeamInvitationList(c *gin.Context) { // Get authorized user info authInfo := oauth.GetAuthorizedInfo(c) if authInfo == nil || authInfo.UserID == "" { @@ -65,7 +65,7 @@ func GinInvitationList(c *gin.Context) { } // Call business logic - result, err := invitationList(c.Request.Context(), authInfo.UserID, teamID, page, pagesize, c.Query("status")) + result, err := teamInvitationList(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 @@ -92,11 +92,70 @@ func GinInvitationList(c *gin.Context) { } // Return the paginated result - c.JSON(http.StatusOK, result) + response.RespondWithSuccess(c, http.StatusOK, result) } -// GinInvitationGet handles GET /teams/:team_id/invitations/:invitation_id - Get invitation details -func GinInvitationGet(c *gin.Context) { +// GinTeamInvitationGetPublic handles GET /user/teams/invitations/:invitation_id - Get invitation details (public) +// This is a public endpoint that doesn't require authentication +// Supports ?locale=zh-CN query parameter for internationalization +func GinTeamInvitationGetPublic(c *gin.Context) { + invitationID := c.Param("invitation_id") + if invitationID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Invitation ID is required", + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + // Get locale from query parameter or Accept-Language header + locale := c.Query("locale") + if locale == "" { + // Try to get from Accept-Language header + acceptLang := c.GetHeader("Accept-Language") + if acceptLang != "" { + // Parse Accept-Language header (e.g., "zh-CN,zh;q=0.9,en;q=0.8") + parts := strings.Split(acceptLang, ",") + if len(parts) > 0 { + langParts := strings.Split(parts[0], ";") + if len(langParts) > 0 { + locale = strings.TrimSpace(langParts[0]) + } + } + } + } + // Default to "en" if no locale specified + if locale == "" { + locale = "en" + } + + // Call business logic (no user authentication required for public access) + publicInvitation, err := teamInvitationGetPublic(c.Request.Context(), invitationID, locale) + 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") || strings.Contains(err.Error(), "expired") { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Invitation not found or expired", + } + response.RespondWithError(c, response.StatusNotFound, errorResp) + } else { + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to retrieve invitation details", + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + } + return + } + + response.RespondWithSuccess(c, http.StatusOK, publicInvitation) +} + +// GinTeamInvitationGet handles GET /teams/:team_id/invitations/:invitation_id - Get invitation details (admin only) +func GinTeamInvitationGet(c *gin.Context) { // Get authorized user info authInfo := oauth.GetAuthorizedInfo(c) if authInfo == nil || authInfo.UserID == "" { @@ -123,7 +182,7 @@ func GinInvitationGet(c *gin.Context) { requestBaseURL := getRequestBaseURL(c) // Call business logic - invitationData, err := invitationGet(c.Request.Context(), authInfo.UserID, teamID, invitationID) + invitationData, err := teamInvitationGet(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 @@ -150,12 +209,12 @@ func GinInvitationGet(c *gin.Context) { } // Convert to response format (with requestBaseURL for building full invitation link) - invitation := mapToInvitationDetailResponse(invitationData, requestBaseURL) - c.JSON(http.StatusOK, invitation) + invitation := mapToTeamInvitationDetailResponse(invitationData, requestBaseURL) + response.RespondWithSuccess(c, http.StatusOK, invitation) } -// GinInvitationCreate handles POST /teams/:team_id/invitations - Send team invitation -func GinInvitationCreate(c *gin.Context) { +// GinTeamInvitationCreate handles POST /teams/:team_id/invitations - Send team invitation +func GinTeamInvitationCreate(c *gin.Context) { // Get authorized user info authInfo := oauth.GetAuthorizedInfo(c) if authInfo == nil || authInfo.UserID == "" { @@ -224,7 +283,7 @@ func GinInvitationCreate(c *gin.Context) { } // Call business logic - invitationID, err := invitationCreate(c.Request.Context(), authInfo.UserID, teamID, invitationData) + invitationID, err := teamInvitationCreate(c.Request.Context(), authInfo.UserID, teamID, invitationData) if err != nil { log.Error("Failed to create invitation: %v", err) // Check error type for appropriate response @@ -263,23 +322,23 @@ func GinInvitationCreate(c *gin.Context) { } // Get the created invitation to return complete data - invitation, err := invitationGet(c.Request.Context(), authInfo.UserID, teamID, invitationID) + invitation, err := teamInvitationGet(c.Request.Context(), authInfo.UserID, teamID, invitationID) if err != nil { log.Error("Failed to retrieve created invitation: %v", err) // Fallback to returning just the ID if retrieval fails - c.JSON(http.StatusCreated, gin.H{"invitation_id": invitationID}) + response.RespondWithSuccess(c, http.StatusCreated, gin.H{"invitation_id": invitationID}) return } // Convert to InvitationResponse (with requestBaseURL for building full invitation link) - invitationResp := convertToInvitationResponse(invitation, requestBaseURL) + invitationResp := convertToTeamInvitationResponse(invitation, requestBaseURL) // Return created invitation with full details (including token) - c.JSON(http.StatusCreated, invitationResp) + response.RespondWithSuccess(c, http.StatusCreated, invitationResp) } -// GinInvitationResend handles PUT /teams/:team_id/invitations/:invitation_id/resend - Resend invitation -func GinInvitationResend(c *gin.Context) { +// GinTeamInvitationResend handles PUT /teams/:team_id/invitations/:invitation_id/resend - Resend invitation +func GinTeamInvitationResend(c *gin.Context) { // Get authorized user info authInfo := oauth.GetAuthorizedInfo(c) if authInfo == nil || authInfo.UserID == "" { @@ -303,7 +362,7 @@ func GinInvitationResend(c *gin.Context) { } // Call business logic - err := invitationResend(c.Request.Context(), authInfo.UserID, teamID, invitationID, getRequestBaseURL(c)) + err := teamInvitationResend(c.Request.Context(), authInfo.UserID, teamID, invitationID, getRequestBaseURL(c)) if err != nil { log.Error("Failed to resend invitation: %v", err) // Check error type for appropriate response @@ -335,11 +394,11 @@ func GinInvitationResend(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{"message": "Invitation resent successfully"}) + response.RespondWithSuccess(c, http.StatusOK, gin.H{"message": "Invitation resent successfully"}) } -// GinInvitationDelete handles DELETE /teams/:team_id/invitations/:invitation_id - Cancel invitation -func GinInvitationDelete(c *gin.Context) { +// GinTeamInvitationDelete handles DELETE /teams/:team_id/invitations/:invitation_id - Cancel invitation +func GinTeamInvitationDelete(c *gin.Context) { // Get authorized user info authInfo := oauth.GetAuthorizedInfo(c) if authInfo == nil || authInfo.UserID == "" { @@ -363,7 +422,7 @@ func GinInvitationDelete(c *gin.Context) { } // Call business logic - err := invitationDelete(c.Request.Context(), authInfo.UserID, teamID, invitationID) + err := teamInvitationDelete(c.Request.Context(), authInfo.UserID, teamID, invitationID) if err != nil { log.Error("Failed to cancel invitation: %v", err) // Check error type for appropriate response @@ -389,16 +448,16 @@ func GinInvitationDelete(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{"message": "Invitation cancelled successfully"}) + response.RespondWithSuccess(c, http.StatusOK, gin.H{"message": "Invitation cancelled successfully"}) } // Yao Process Handlers (for Yao application calls) -// ProcessInvitationList user.invitation.list Invitation list processor +// ProcessTeamInvitationList user.team.invitation.list Team 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{} { +func ProcessTeamInvitationList(process *process.Process) interface{} { process.ValidateArgNums(2) // Get user_id from session @@ -434,19 +493,19 @@ func ProcessInvitationList(process *process.Process) interface{} { } // Call business logic - result, err := invitationList(ctx, userIDStr, teamID, page, pagesize, status) + result, err := teamInvitationList(ctx, userIDStr, teamID, page, pagesize, status) if err != nil { - exception.New("failed to list invitations: %s", 500, err.Error()).Throw() + exception.New("failed to list team invitations: %s", 500, err.Error()).Throw() } return result } -// ProcessInvitationGet user.invitation.get Invitation get processor +// ProcessTeamInvitationGet user.team.invitation.get Team invitation get processor // Args[0] string: team_id // Args[1] string: invitation_id // Return: map: Invitation details -func ProcessInvitationGet(process *process.Process) interface{} { +func ProcessTeamInvitationGet(process *process.Process) interface{} { process.ValidateArgNums(2) // Get user_id from session @@ -466,19 +525,19 @@ func ProcessInvitationGet(process *process.Process) interface{} { } // Call business logic - result, err := invitationGet(ctx, userIDStr, teamID, invitationID) + result, err := teamInvitationGet(ctx, userIDStr, teamID, invitationID) if err != nil { - exception.New("failed to get invitation: %s", 500, err.Error()).Throw() + exception.New("failed to get team invitation: %s", 500, err.Error()).Throw() } return result } -// ProcessInvitationCreate user.invitation.create Invitation create processor +// ProcessTeamInvitationCreate user.team.invitation.create Team 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{} { +func ProcessTeamInvitationCreate(process *process.Process) interface{} { process.ValidateArgNums(2) // Get user_id from session @@ -506,9 +565,9 @@ func ProcessInvitationCreate(process *process.Process) interface{} { } // Call business logic - invitationID, err := invitationCreate(ctx, userIDStr, teamID, invitationData) + invitationID, err := teamInvitationCreate(ctx, userIDStr, teamID, invitationData) if err != nil { - exception.New("failed to create invitation: %s", 500, err.Error()).Throw() + exception.New("failed to create team invitation: %s", 500, err.Error()).Throw() } return map[string]interface{}{ @@ -516,11 +575,11 @@ func ProcessInvitationCreate(process *process.Process) interface{} { } } -// ProcessInvitationResend user.invitation.resend Invitation resend processor +// ProcessTeamInvitationResend user.team.invitation.resend Team invitation resend processor // Args[0] string: team_id // Args[1] string: invitation_id // Return: map: {"message": "success"} -func ProcessInvitationResend(process *process.Process) interface{} { +func ProcessTeamInvitationResend(process *process.Process) interface{} { process.ValidateArgNums(2) // Get user_id from session @@ -540,9 +599,9 @@ func ProcessInvitationResend(process *process.Process) interface{} { } // Call business logic (no requestBaseURL available in process context) - err := invitationResend(ctx, userIDStr, teamID, invitationID, "") + err := teamInvitationResend(ctx, userIDStr, teamID, invitationID, "") if err != nil { - exception.New("failed to resend invitation: %s", 500, err.Error()).Throw() + exception.New("failed to resend team invitation: %s", 500, err.Error()).Throw() } return map[string]interface{}{ @@ -550,11 +609,11 @@ func ProcessInvitationResend(process *process.Process) interface{} { } } -// ProcessInvitationDelete user.invitation.delete Invitation delete processor +// ProcessTeamInvitationDelete user.team.invitation.delete Team invitation delete processor // Args[0] string: team_id // Args[1] string: invitation_id // Return: map: {"message": "success"} -func ProcessInvitationDelete(process *process.Process) interface{} { +func ProcessTeamInvitationDelete(process *process.Process) interface{} { process.ValidateArgNums(2) // Get user_id from session @@ -574,9 +633,9 @@ func ProcessInvitationDelete(process *process.Process) interface{} { } // Call business logic - err := invitationDelete(ctx, userIDStr, teamID, invitationID) + err := teamInvitationDelete(ctx, userIDStr, teamID, invitationID) if err != nil { - exception.New("failed to delete invitation: %s", 500, err.Error()).Throw() + exception.New("failed to delete team invitation: %s", 500, err.Error()).Throw() } return map[string]interface{}{ @@ -622,13 +681,13 @@ func getRequestBaseURL(c *gin.Context) string { return fmt.Sprintf("%s://%s", scheme, host) } -// buildInvitationLink constructs a full invitation link from invitation_id, token and team configuration +// buildTeamInvitationLink constructs a full invitation link from invitation_id, token and team configuration // This is a centralized function to ensure consistency across email sending and link generation // Format: // - With team config baseURL: {base_url}/{invitation_id}/{token} // - With requestBaseURL (from HTTP request): {scheme}://{host}{AdminRoot}team/invite/{invitation_id}/{token} // - Without any baseURL (fallback): {AdminRoot}team/invite/{invitation_id}/{token} -func buildInvitationLink(invitationID, token string, teamConfig *TeamConfig, requestBaseURL string) string { +func buildTeamInvitationLink(invitationID, token string, teamConfig *TeamConfig, requestBaseURL string) string { // Priority 1: Use team config baseURL if specified if teamConfig != nil && teamConfig.Invite != nil && teamConfig.Invite.BaseURL != "" { baseURL := teamConfig.Invite.BaseURL @@ -655,8 +714,8 @@ func buildInvitationLink(invitationID, token string, teamConfig *TeamConfig, req return fmt.Sprintf("%s/team/invite/%s/%s", adminRoot, invitationID, token) } -// 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) { +// teamInvitationList handles the business logic for listing team invitations +func teamInvitationList(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 { @@ -704,8 +763,102 @@ func invitationList(ctx context.Context, userID, teamID string, page, pagesize i 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) { +// teamInvitationGetPublic handles the business logic for getting a specific team invitation (public access) +// This function doesn't require authentication and is used for invitation recipients +// locale parameter is used to get localized role labels +func teamInvitationGetPublic(ctx context.Context, invitationID, locale string) (*PublicInvitationResponse, error) { + // 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) + } + + // Only return if it's a pending invitation + if toString(invitationData["status"]) != "pending" { + return nil, fmt.Errorf("invitation not found or no longer pending") + } + + // Check if invitation has expired + expiresAt := invitationData["invitation_expires_at"] + if expiresAt != nil { + var expiryTime time.Time + switch v := expiresAt.(type) { + case time.Time: + expiryTime = v + case string: + parsed, err := time.Parse(time.RFC3339, v) + if err == nil { + expiryTime = parsed + } + } + if !expiryTime.IsZero() && time.Now().After(expiryTime) { + return nil, fmt.Errorf("invitation has expired") + } + } + + // Get team information + teamID := toString(invitationData["team_id"]) + team, err := provider.GetTeam(ctx, teamID) + if err != nil { + log.Warn("Failed to get team information: %v", err) + team = maps.MapStrAny{"name": "Team"} + } + + // Get inviter information + inviterID := toString(invitationData["invited_by"]) + var inviterInfo *InviterInfo + if inviterID != "" { + inviter, err := provider.GetUser(ctx, inviterID) + if err == nil { + inviterInfo = &InviterInfo{ + Name: toString(inviter["name"]), + Picture: toString(inviter["picture"]), + } + // Fallback to masked email if name is empty (for privacy protection) + if inviterInfo.Name == "" { + inviterInfo.Name = maskEmail(toString(inviter["email"])) + } + } + } + + // Get role label from team config using provided locale + roleID := toString(invitationData["role_id"]) + roleLabel := "" + teamConfig := GetTeamConfig(locale) + if teamConfig != nil && teamConfig.Roles != nil { + for _, role := range teamConfig.Roles { + if role.RoleID == roleID { + roleLabel = role.Label + break + } + } + } + + // Build public response (exclude sensitive data like IDs) + publicResponse := &PublicInvitationResponse{ + InvitationID: toString(invitationData["invitation_id"]), + TeamName: toString(team["name"]), + TeamLogo: toString(team["logo"]), + TeamDescription: toString(team["description"]), + RoleLabel: roleLabel, + Status: toString(invitationData["status"]), + InvitedAt: toTimeString(invitationData["invited_at"]), + InvitationExpiresAt: toTimeString(invitationData["invitation_expires_at"]), + Message: toString(invitationData["message"]), + InviterInfo: inviterInfo, + } + + return publicResponse, nil +} + +// teamInvitationGet handles the business logic for getting a specific team invitation (admin access) +func teamInvitationGet(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 { @@ -742,11 +895,11 @@ func invitationGet(ctx context.Context, userID, teamID, invitationID string) (ma return invitationData, nil } -// invitationCreate handles the business logic for creating a team invitation +// teamInvitationCreate handles the business logic for creating a team invitation // Supports two scenarios: // 1. Email invitation: provide email and role, send invitation link via email // 2. Link invitation: create invitation link for display in frontend, customizable expiry -func invitationCreate(ctx context.Context, userID, teamID string, invitationData maps.MapStrAny) (string, error) { +func teamInvitationCreate(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 { @@ -833,13 +986,13 @@ func invitationCreate(ctx context.Context, userID, teamID string, invitationData } // Generate invitation token - token, err := generateInvitationToken() + token, err := generateTeamInvitationToken() if err != nil { return "", fmt.Errorf("failed to generate invitation token: %w", err) } // Calculate expiry duration - expiryDuration, err := getInvitationExpiry(invitationData) + expiryDuration, err := getTeamInvitationExpiry(invitationData) if err != nil { return "", fmt.Errorf("failed to parse expiry duration: %w", err) } @@ -892,7 +1045,7 @@ func invitationCreate(ctx context.Context, userID, teamID string, invitationData emailData["request_base_url"] = requestBaseURL emailData["settings"] = savedSettings // Restore settings - err := sendInvitationEmail(bgCtx, inviteeEmail, inviterName, teamName, token, invitationID, emailData) + err := sendTeamInvitationEmail(bgCtx, inviteeEmail, inviterName, teamName, token, invitationID, emailData) if err != nil { log.Error("Failed to send invitation email: %v", err) } else { @@ -904,8 +1057,8 @@ func invitationCreate(ctx context.Context, userID, teamID string, invitationData return invitationID, nil } -// invitationResend handles the business logic for resending a team invitation -func invitationResend(ctx context.Context, userID, teamID, invitationID, requestBaseURL string) error { +// teamInvitationResend handles the business logic for resending a team invitation +func teamInvitationResend(ctx context.Context, userID, teamID, invitationID, requestBaseURL string) error { // Check if user has access to the team (write permission: owner only) isOwner, _, err := checkTeamAccess(ctx, teamID, userID) if err != nil { @@ -958,13 +1111,13 @@ func invitationResend(ctx context.Context, userID, teamID, invitationID, request } // Generate new invitation token - newToken, err := generateInvitationToken() + newToken, err := generateTeamInvitationToken() if err != nil { return fmt.Errorf("failed to generate new invitation token: %w", err) } // Calculate expiry duration (use existing expiry from original invitation data) - expiryDuration, err := getInvitationExpiry(invitationData) + expiryDuration, err := getTeamInvitationExpiry(invitationData) if err != nil { log.Warn("Failed to parse expiry duration: %v, using default", err) expiryDuration = 7 * 24 * time.Hour @@ -1007,7 +1160,7 @@ func invitationResend(ctx context.Context, userID, teamID, invitationID, request go func() { // Use background context for async operation bgCtx := context.Background() - err := sendInvitationEmail(bgCtx, inviteeEmail, inviterName, teamName, newToken, invitationID, invitationData) + err := sendTeamInvitationEmail(bgCtx, inviteeEmail, inviterName, teamName, newToken, invitationID, invitationData) if err != nil { log.Error("Failed to resend invitation email: %v", err) } else { @@ -1019,8 +1172,8 @@ func invitationResend(ctx context.Context, userID, teamID, invitationID, request return nil } -// invitationDelete handles the business logic for cancelling a team invitation -func invitationDelete(ctx context.Context, userID, teamID, invitationID string) error { +// teamInvitationDelete handles the business logic for cancelling a team invitation +func teamInvitationDelete(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 { @@ -1065,8 +1218,8 @@ func invitationDelete(ctx context.Context, userID, teamID, invitationID string) // Private Helper Functions (internal use only) -// generateInvitationToken generates a secure random token for invitations -func generateInvitationToken() (string, error) { +// generateTeamInvitationToken generates a secure random token for invitations +func generateTeamInvitationToken() (string, error) { bytes := make([]byte, 32) // 32 bytes = 256 bits _, err := rand.Read(bytes) if err != nil { @@ -1076,9 +1229,9 @@ func generateInvitationToken() (string, error) { return strings.TrimRight(base64.URLEncoding.EncodeToString(bytes), "="), nil } -// getInvitationExpiry calculates the expiry duration for an invitation +// getTeamInvitationExpiry calculates the expiry duration for an invitation // Priority: 1. Request expiry parameter, 2. Team config, 3. Default (7 days) -func getInvitationExpiry(invitationData maps.MapStrAny) (time.Duration, error) { +func getTeamInvitationExpiry(invitationData maps.MapStrAny) (time.Duration, error) { // Default expiry: 7 days defaultExpiry := 7 * 24 * time.Hour @@ -1128,8 +1281,8 @@ func getInvitationExpiry(invitationData maps.MapStrAny) (time.Duration, error) { return defaultExpiry, nil } -// sendInvitationEmail sends an invitation email using messenger service -func sendInvitationEmail(ctx context.Context, email, inviterName, teamName, token, invitationID string, invitationData maps.MapStrAny) error { +// sendTeamInvitationEmail sends an invitation email using messenger service +func sendTeamInvitationEmail(ctx context.Context, email, inviterName, teamName, token, invitationID string, invitationData maps.MapStrAny) error { // Check if messenger is available if messenger.Instance == nil { return fmt.Errorf("messenger service not available") @@ -1179,7 +1332,7 @@ func sendInvitationEmail(ctx context.Context, email, inviterName, teamName, toke requestBaseURL := toString(invitationData["request_base_url"]) // Build invitation link using centralized helper function - invitationLink := buildInvitationLink(invitationID, token, teamConfig, requestBaseURL) + invitationLink := buildTeamInvitationLink(invitationID, token, teamConfig, requestBaseURL) // Prepare template data for messenger templateData := messengertypes.TemplateData{ @@ -1203,13 +1356,13 @@ func sendInvitationEmail(ctx context.Context, email, inviterName, teamName, toke return nil } -// convertToInvitationResponse converts a map to InvitationResponse (alias for mapToInvitationResponse) -func convertToInvitationResponse(data maps.MapStrAny, requestBaseURL string) InvitationResponse { - return mapToInvitationResponse(maps.MapStr(data), requestBaseURL) +// convertToTeamInvitationResponse converts a map to InvitationResponse (alias for mapToTeamInvitationResponse) +func convertToTeamInvitationResponse(data maps.MapStrAny, requestBaseURL string) InvitationResponse { + return mapToTeamInvitationResponse(maps.MapStr(data), requestBaseURL) } -// mapToInvitationResponse converts a map to InvitationResponse -func mapToInvitationResponse(data maps.MapStr, requestBaseURL string) InvitationResponse { +// mapToTeamInvitationResponse converts a map to InvitationResponse +func mapToTeamInvitationResponse(data maps.MapStr, requestBaseURL string) InvitationResponse { invitation := InvitationResponse{ ID: toInt64(data["id"]), InvitationID: toString(data["invitation_id"]), @@ -1251,16 +1404,16 @@ func mapToInvitationResponse(data maps.MapStr, requestBaseURL string) Invitation // Build invitation link if token is available if invitation.InvitationToken != "" && invitation.InvitationID != "" { teamConfig := GetTeamConfig(locale) - invitation.InvitationLink = buildInvitationLink(invitation.InvitationID, invitation.InvitationToken, teamConfig, requestBaseURL) + invitation.InvitationLink = buildTeamInvitationLink(invitation.InvitationID, invitation.InvitationToken, teamConfig, requestBaseURL) } return invitation } -// mapToInvitationDetailResponse converts a map to InvitationDetailResponse -func mapToInvitationDetailResponse(data maps.MapStr, requestBaseURL string) InvitationDetailResponse { +// mapToTeamInvitationDetailResponse converts a map to InvitationDetailResponse +func mapToTeamInvitationDetailResponse(data maps.MapStr, requestBaseURL string) InvitationDetailResponse { invitation := InvitationDetailResponse{ - InvitationResponse: mapToInvitationResponse(data, requestBaseURL), + InvitationResponse: mapToTeamInvitationResponse(data, requestBaseURL), } // Add user info if available (could be joined from user table) diff --git a/openapi/user/types.go b/openapi/user/types.go index 7e9f1f2a..bdc57abb 100644 --- a/openapi/user/types.go +++ b/openapi/user/types.go @@ -331,6 +331,27 @@ type InvitationDetailResponse struct { TeamInfo map[string]interface{} `json:"team_info,omitempty"` } +// PublicInvitationResponse represents a public team invitation (for invitation recipients) +// This type excludes sensitive information like tokens, database IDs, and timestamps +type PublicInvitationResponse struct { + InvitationID string `json:"invitation_id"` + TeamName string `json:"team_name"` + TeamLogo string `json:"team_logo"` // Always return, empty string if not set + TeamDescription string `json:"team_description"` // Always return, empty string if not set + RoleLabel string `json:"role_label,omitempty"` + Status string `json:"status"` + InvitedAt string `json:"invited_at"` + InvitationExpiresAt string `json:"invitation_expires_at,omitempty"` + Message string `json:"message,omitempty"` + InviterInfo *InviterInfo `json:"inviter_info,omitempty"` // Inviter's public info +} + +// InviterInfo represents public information about the person who sent the invitation +type InviterInfo struct { + Name string `json:"name,omitempty"` + Picture string `json:"picture"` // Always return, empty string if not set +} + // CreateInvitationRequest represents the request to send a team invitation type CreateInvitationRequest struct { UserID string `json:"user_id,omitempty"` // Optional for unregistered users diff --git a/openapi/user/user.go b/openapi/user/user.go index 3bdb077e..2342bfc8 100644 --- a/openapi/user/user.go +++ b/openapi/user/user.go @@ -11,11 +11,16 @@ import ( func init() { // Register user process handlers process.RegisterGroup("user", map[string]process.Handler{ - "team.list": ProcessTeamList, - "team.get": ProcessTeamGet, - "team.create": ProcessTeamCreate, - "team.update": ProcessTeamUpdate, - "team.delete": ProcessTeamDelete, + "team.list": ProcessTeamList, + "team.get": ProcessTeamGet, + "team.create": ProcessTeamCreate, + "team.update": ProcessTeamUpdate, + "team.delete": ProcessTeamDelete, + "team.invitation.list": ProcessTeamInvitationList, + "team.invitation.get": ProcessTeamInvitationGet, + "team.invitation.create": ProcessTeamInvitationCreate, + "team.invitation.resend": ProcessTeamInvitationResend, + "team.invitation.delete": ProcessTeamInvitationDelete, }) } @@ -51,6 +56,10 @@ func Attach(group *gin.RouterGroup, oauth types.OAuth) { // User Team Management func attachTeam(group *gin.RouterGroup, oauth types.OAuth) { + // Public endpoint for viewing team invitations (no auth required) + // Must be registered BEFORE the team group with auth guard + group.GET("/teams/invitations/:invitation_id", GinTeamInvitationGetPublic) // GET /user/teams/invitations/:invitation_id - Get invitation details (public) + team := group.Group("/teams") // Protected endpoints (authentication required) @@ -74,11 +83,11 @@ func attachTeam(group *gin.RouterGroup, oauth types.OAuth) { team.DELETE("/:id/members/:member_id", GinMemberDelete) // DELETE /teams/:id/members/:member_id - Remove member // Team Invitations - Nested resource endpoints - team.GET("/:id/invitations", GinInvitationList) // GET /teams/:id/invitations - List invitations - team.POST("/:id/invitations", GinInvitationCreate) // POST /teams/:id/invitations - Send invitation - team.GET("/:id/invitations/:invitation_id", GinInvitationGet) // GET /teams/:id/invitations/:invitation_id - Get invitation - team.PUT("/:id/invitations/:invitation_id/resend", GinInvitationResend) // PUT /teams/:id/invitations/:invitation_id/resend - Resend invitation - team.DELETE("/:id/invitations/:invitation_id", GinInvitationDelete) // DELETE /teams/:id/invitations/:invitation_id - Cancel invitation + team.GET("/:id/invitations", GinTeamInvitationList) // GET /teams/:id/invitations - List invitations + team.POST("/:id/invitations", GinTeamInvitationCreate) // POST /teams/:id/invitations - Send invitation + team.GET("/:id/invitations/:invitation_id", GinTeamInvitationGet) // GET /teams/:id/invitations/:invitation_id - Get invitation (admin) + team.PUT("/:id/invitations/:invitation_id/resend", GinTeamInvitationResend) // PUT /teams/:id/invitations/:invitation_id/resend - Resend invitation + team.DELETE("/:id/invitations/:invitation_id", GinTeamInvitationDelete) // DELETE /teams/:id/invitations/:invitation_id - Cancel invitation } // Invitation Response Management (Cross-module invitation handling) diff --git a/openapi/user/utils.go b/openapi/user/utils.go index 6b6d9709..1437585b 100644 --- a/openapi/user/utils.go +++ b/openapi/user/utils.go @@ -3,6 +3,7 @@ package user import ( "fmt" "strconv" + "strings" "time" "github.com/yaoapp/gou/process" @@ -147,3 +148,45 @@ func toTimeString(v interface{}) string { return "" } } + +// Security Utilities + +// maskEmail masks an email address for privacy protection +// Keeps the first and last character of the local part, masks the middle with *** +// Examples: +// - "john.doe@example.com" -> "j***e@example.com" +// - "a@example.com" -> "a***@example.com" +// - "ab@example.com" -> "a***b@example.com" +// +// Returns empty string for invalid email or empty input +func maskEmail(email string) string { + if email == "" { + return "" + } + + // Split email into local and domain parts + parts := strings.Split(email, "@") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "" // Invalid email format + } + + local := parts[0] + domain := parts[1] + + // Mask the local part + var masked string + localLen := len(local) + switch localLen { + case 1: + // Single character: show it with *** + masked = local + "***" + case 2: + // Two characters: show first + *** + last + masked = string(local[0]) + "***" + string(local[1]) + default: + // Three or more characters: show first + *** + last + masked = string(local[0]) + "***" + string(local[localLen-1]) + } + + return masked + "@" + domain +}