package user import ( "context" "crypto/rand" "encoding/base64" "fmt" "net/http" "strconv" "strings" "time" "github.com/gin-gonic/gin" "github.com/yaoapp/gou/model" "github.com/yaoapp/gou/process" "github.com/yaoapp/kun/exception" "github.com/yaoapp/kun/log" "github.com/yaoapp/kun/maps" "github.com/yaoapp/yao/attachment" "github.com/yaoapp/yao/messenger" messengertypes "github.com/yaoapp/yao/messenger/types" "github.com/yaoapp/yao/openapi/oauth" "github.com/yaoapp/yao/openapi/oauth/authorized" oauthTypes "github.com/yaoapp/yao/openapi/oauth/types" "github.com/yaoapp/yao/openapi/response" "github.com/yaoapp/yao/openapi/utils" "github.com/yaoapp/yao/share" ) // User Team Invitation Management Handlers // 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 == "" { errorResp := &response.ErrorResponse{ Code: response.ErrInvalidClient.Code, ErrorDescription: "User not authenticated", } response.RespondWithError(c, response.StatusUnauthorized, errorResp) return } teamID := c.Param("id") if teamID == "" { errorResp := &response.ErrorResponse{ Code: response.ErrInvalidRequest.Code, ErrorDescription: "Team ID is required", } response.RespondWithError(c, response.StatusBadRequest, errorResp) return } // 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 := 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 if strings.Contains(err.Error(), "not found") { errorResp := &response.ErrorResponse{ Code: response.ErrInvalidRequest.Code, ErrorDescription: "Team not found", } response.RespondWithError(c, response.StatusNotFound, errorResp) } else if strings.Contains(err.Error(), "access denied") { errorResp := &response.ErrorResponse{ Code: response.ErrAccessDenied.Code, ErrorDescription: err.Error(), } response.RespondWithError(c, response.StatusForbidden, errorResp) } else { errorResp := &response.ErrorResponse{ Code: response.ErrServerError.Code, ErrorDescription: "Failed to retrieve team invitations", } response.RespondWithError(c, response.StatusInternalServerError, errorResp) } return } // Return the paginated result response.RespondWithSuccess(c, http.StatusOK, result) } // 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 == "" { errorResp := &response.ErrorResponse{ Code: response.ErrInvalidClient.Code, ErrorDescription: "User not authenticated", } response.RespondWithError(c, response.StatusUnauthorized, errorResp) return } teamID := c.Param("id") invitationID := c.Param("invitation_id") if teamID == "" || invitationID == "" { errorResp := &response.ErrorResponse{ Code: response.ErrInvalidRequest.Code, ErrorDescription: "Team ID and Invitation ID are required", } response.RespondWithError(c, response.StatusBadRequest, errorResp) return } // Extract base URL from request requestBaseURL := getRequestBaseURL(c) // Call business logic 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 if strings.Contains(err.Error(), "not found") { errorResp := &response.ErrorResponse{ Code: response.ErrInvalidRequest.Code, ErrorDescription: "Invitation not found", } response.RespondWithError(c, response.StatusNotFound, errorResp) } else if strings.Contains(err.Error(), "access denied") { errorResp := &response.ErrorResponse{ Code: response.ErrAccessDenied.Code, ErrorDescription: err.Error(), } response.RespondWithError(c, response.StatusForbidden, errorResp) } else { errorResp := &response.ErrorResponse{ Code: response.ErrServerError.Code, ErrorDescription: "Failed to retrieve invitation details", } response.RespondWithError(c, response.StatusInternalServerError, errorResp) } return } // Convert to response format (with requestBaseURL for building full invitation link) invitation := mapToTeamInvitationDetailResponse(invitationData, requestBaseURL) response.RespondWithSuccess(c, http.StatusOK, invitation) } // GinTeamInvitationCreate handles POST /teams/:team_id/invitations - Send team invitation func GinTeamInvitationCreate(c *gin.Context) { // Get authorized user info authInfo := authorized.GetInfo(c) if authInfo.Constraints.OwnerOnly || authInfo.Constraints.TeamOnly { if authInfo == nil || authInfo.UserID == "" { errorResp := &response.ErrorResponse{ Code: response.ErrInvalidClient.Code, ErrorDescription: "User not authenticated", } response.RespondWithError(c, response.StatusUnauthorized, errorResp) return } } teamID := c.Param("id") if teamID == "" { errorResp := &response.ErrorResponse{ Code: response.ErrInvalidRequest.Code, ErrorDescription: "Team ID is required", } response.RespondWithError(c, response.StatusBadRequest, errorResp) return } // Parse request body var req CreateInvitationRequest if err := c.ShouldBindJSON(&req); err != nil { errorResp := &response.ErrorResponse{ Code: response.ErrInvalidRequest.Code, ErrorDescription: "Invalid request body: " + err.Error(), } response.RespondWithError(c, response.StatusBadRequest, errorResp) return } // Extract base URL from request requestBaseURL := getRequestBaseURL(c) // Prepare invitation data invitationData := authInfo.WithCreateScope(maps.MapStrAny{ "user_id": req.UserID, "email": req.Email, "member_type": req.MemberType, "role_id": req.RoleID, "message": req.Message, "expiry": req.Expiry, "request_base_url": requestBaseURL, }) // Prepare settings settings := &InvitationSettings{} if req.Settings != nil { settings = req.Settings } // Add send_email from top-level field (for backward compatibility) if req.SendEmail != nil { settings.SendEmail = *req.SendEmail } // Add locale from top-level field (for backward compatibility) if req.Locale != "" { settings.Locale = req.Locale } // Add settings to invitation data if settings.SendEmail || settings.Locale != "" { invitationData["settings"] = settings } // Call business logic 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 if strings.Contains(err.Error(), "not found") { errorResp := &response.ErrorResponse{ Code: response.ErrInvalidRequest.Code, ErrorDescription: "Team not found", } response.RespondWithError(c, response.StatusNotFound, errorResp) } else if strings.Contains(err.Error(), "access denied") { errorResp := &response.ErrorResponse{ Code: response.ErrAccessDenied.Code, ErrorDescription: err.Error(), } response.RespondWithError(c, response.StatusForbidden, errorResp) } else if strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "already invited") { errorResp := &response.ErrorResponse{ Code: response.ErrInvalidRequest.Code, ErrorDescription: err.Error(), } response.RespondWithError(c, response.StatusConflict, errorResp) } else if strings.Contains(err.Error(), "email is required") || strings.Contains(err.Error(), "is required") { errorResp := &response.ErrorResponse{ Code: response.ErrInvalidRequest.Code, ErrorDescription: err.Error(), } response.RespondWithError(c, response.StatusBadRequest, errorResp) } else { errorResp := &response.ErrorResponse{ Code: response.ErrServerError.Code, ErrorDescription: "Failed to send invitation", } response.RespondWithError(c, response.StatusInternalServerError, errorResp) } return } // Get the created invitation to return complete data 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 response.RespondWithSuccess(c, http.StatusCreated, gin.H{"invitation_id": invitationID}) return } // Convert to InvitationResponse (with requestBaseURL for building full invitation link) invitationResp := convertToTeamInvitationResponse(invitation, requestBaseURL) // Return created invitation with full details (including token) response.RespondWithSuccess(c, http.StatusCreated, invitationResp) } // 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 == "" { errorResp := &response.ErrorResponse{ Code: response.ErrInvalidClient.Code, ErrorDescription: "User not authenticated", } response.RespondWithError(c, response.StatusUnauthorized, errorResp) return } teamID := c.Param("id") invitationID := c.Param("invitation_id") if teamID == "" || invitationID == "" { errorResp := &response.ErrorResponse{ Code: response.ErrInvalidRequest.Code, ErrorDescription: "Team ID and Invitation ID are required", } response.RespondWithError(c, response.StatusBadRequest, errorResp) return } // Parse request body for locale var requestBody struct { Locale string `json:"locale"` } if err := c.ShouldBindJSON(&requestBody); err != nil { // If no body or invalid JSON, try query parameter as fallback requestBody.Locale = c.Query("locale") } // Get locale from request body or query parameter, default to "en" locale := requestBody.Locale if locale == "" { locale = c.Query("locale") } if locale == "" { locale = "en" } // Get request base URL for invitation link generation requestBaseURL := getRequestBaseURL(c) // Call business logic err := teamInvitationResend(c.Request.Context(), authInfo.UserID, teamID, invitationID, requestBaseURL, locale) if err != nil { log.Error("Failed to resend invitation: %v", err) // Check error type for appropriate response if strings.Contains(err.Error(), "not found") { errorResp := &response.ErrorResponse{ Code: response.ErrInvalidRequest.Code, ErrorDescription: "Invitation not found", } response.RespondWithError(c, response.StatusNotFound, errorResp) } else if strings.Contains(err.Error(), "access denied") { errorResp := &response.ErrorResponse{ Code: response.ErrAccessDenied.Code, ErrorDescription: err.Error(), } response.RespondWithError(c, response.StatusForbidden, errorResp) } else if strings.Contains(err.Error(), "already accepted") || strings.Contains(err.Error(), "invalid status") { errorResp := &response.ErrorResponse{ Code: response.ErrInvalidRequest.Code, ErrorDescription: err.Error(), } response.RespondWithError(c, response.StatusBadRequest, errorResp) } else { errorResp := &response.ErrorResponse{ Code: response.ErrServerError.Code, ErrorDescription: "Failed to resend invitation", } response.RespondWithError(c, response.StatusInternalServerError, errorResp) } return } response.RespondWithSuccess(c, http.StatusOK, gin.H{"message": "Invitation resent successfully"}) } // 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 == "" { errorResp := &response.ErrorResponse{ Code: response.ErrInvalidClient.Code, ErrorDescription: "User not authenticated", } response.RespondWithError(c, response.StatusUnauthorized, errorResp) return } teamID := c.Param("id") invitationID := c.Param("invitation_id") if teamID == "" || invitationID == "" { errorResp := &response.ErrorResponse{ Code: response.ErrInvalidRequest.Code, ErrorDescription: "Team ID and Invitation ID are required", } response.RespondWithError(c, response.StatusBadRequest, errorResp) return } // Call business logic err := 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 if strings.Contains(err.Error(), "not found") { errorResp := &response.ErrorResponse{ Code: response.ErrInvalidRequest.Code, ErrorDescription: "Invitation not found", } response.RespondWithError(c, response.StatusNotFound, errorResp) } else if strings.Contains(err.Error(), "access denied") { errorResp := &response.ErrorResponse{ Code: response.ErrAccessDenied.Code, ErrorDescription: err.Error(), } response.RespondWithError(c, response.StatusForbidden, errorResp) } else { errorResp := &response.ErrorResponse{ Code: response.ErrServerError.Code, ErrorDescription: "Failed to cancel invitation", } response.RespondWithError(c, response.StatusInternalServerError, errorResp) } return } response.RespondWithSuccess(c, http.StatusOK, gin.H{"message": "Invitation cancelled successfully"}) } // GinTeamInvitationAccept handles POST /user/teams/invitations/:invitation_id/accept - Accept invitation and login to team func GinTeamInvitationAccept(c *gin.Context) { // Get authorized user info authInfo := authorized.GetInfo(c) if authInfo == nil || authInfo.UserID == "" { errorResp := &response.ErrorResponse{ Code: response.ErrInvalidClient.Code, ErrorDescription: "User not authenticated", } response.RespondWithError(c, response.StatusUnauthorized, errorResp) return } ctx := c.Request.Context() // Use authInfo.UserID directly - it might be OAuth subject, but LoginByTeamID will handle user creation userID := authInfo.UserID 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 } // Parse request body to get token var req struct { Token string `json:"token" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { errorResp := &response.ErrorResponse{ Code: response.ErrInvalidRequest.Code, ErrorDescription: "Invalid request body: token is required", } response.RespondWithError(c, response.StatusBadRequest, errorResp) return } // Get user provider instance provider, err := getUserProvider() if err != nil { log.Error("Failed to get user provider: %v", err) errorResp := &response.ErrorResponse{ Code: response.ErrServerError.Code, ErrorDescription: "Failed to process invitation", } response.RespondWithError(c, response.StatusInternalServerError, errorResp) return } // Get invitation details first to retrieve team_id invitationData, err := provider.GetMemberByInvitationID(ctx, invitationID) if err != nil { log.Error("Failed to get invitation: %v", err) errorResp := &response.ErrorResponse{ Code: response.ErrInvalidRequest.Code, ErrorDescription: "Invitation not found", } response.RespondWithError(c, response.StatusNotFound, errorResp) return } // Get team_id from invitation teamID := utils.ToString(invitationData["team_id"]) if teamID == "" { log.Error("Invalid invitation: missing team_id") errorResp := &response.ErrorResponse{ Code: response.ErrServerError.Code, ErrorDescription: "Invalid invitation data", } response.RespondWithError(c, response.StatusInternalServerError, errorResp) return } // Accept the invitation (will update user_id if invitation doesn't have one) err = provider.AcceptInvitation(ctx, invitationID, req.Token, userID) if err != nil { log.Error("Failed to accept invitation: %v", err) // Check error type for appropriate response if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "already accepted") { errorResp := &response.ErrorResponse{ Code: response.ErrInvalidRequest.Code, ErrorDescription: "Invitation not found or already accepted", } response.RespondWithError(c, response.StatusNotFound, errorResp) } else if strings.Contains(err.Error(), "expired") { errorResp := &response.ErrorResponse{ Code: response.ErrInvalidRequest.Code, ErrorDescription: "Invitation has expired", } response.RespondWithError(c, response.StatusBadRequest, errorResp) } else { errorResp := &response.ErrorResponse{ Code: response.ErrServerError.Code, ErrorDescription: "Failed to accept invitation", } response.RespondWithError(c, response.StatusInternalServerError, errorResp) } return } // Prepare login context with full device/platform information loginCtx := makeLoginContext(c) // Login with the team that was just joined // Note: userID must exist in database (user table) loginResponse, err := LoginByTeamID(userID, teamID, loginCtx) if err != nil { log.Error("Failed to login with team: %v", err) errorResp := &response.ErrorResponse{ Code: response.ErrServerError.Code, ErrorDescription: "Invitation accepted but failed to login: " + err.Error(), } response.RespondWithError(c, response.StatusInternalServerError, errorResp) return } // Revoke the current token if it exists (similar to team selection) currentToken := oauth.OAuth.GetAccessToken(c) if currentToken != "" { if err := oauth.OAuth.Revoke(ctx, currentToken, "access_token"); err != nil { // Log the error but don't fail the request log.Warn("Failed to revoke previous token: %v", err) } } // Send secure cookies (access token, refresh token, and session ID) SendLoginCookies(c, loginResponse, "") // Return the new tokens in response body response.RespondWithSuccess(c, http.StatusOK, loginResponse) } // Yao Process Handlers (for Yao application calls) // 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 ProcessTeamInvitationList(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 := int(utils.ToInt64(queryMap["page"])); p > 0 { page = p } if ps := int(utils.ToInt64(queryMap["pagesize"])); ps > 0 && ps <= 100 { pagesize = ps } // Get status filter status := utils.ToString(queryMap["status"]) // Get context ctx := process.Context if ctx == nil { ctx = context.Background() } // Call business logic result, err := teamInvitationList(ctx, userIDStr, teamID, page, pagesize, status) if err != nil { exception.New("failed to list team invitations: %s", 500, err.Error()).Throw() } return result } // ProcessTeamInvitationGet user.team.invitation.get Team invitation get processor // Args[0] string: team_id // Args[1] string: invitation_id // Return: map: Invitation details func ProcessTeamInvitationGet(process *process.Process) interface{} { process.ValidateArgNums(2) // Get user_id from session userIDStr := GetUserIDFromSession(process) teamID := process.ArgsString(0) invitationID := process.ArgsString(1) if teamID == "" || invitationID == "" { exception.New("team_id and invitation_id are required", 400).Throw() } // Get context ctx := process.Context if ctx == nil { ctx = context.Background() } // Call business logic result, err := teamInvitationGet(ctx, userIDStr, teamID, invitationID) if err != nil { exception.New("failed to get team invitation: %s", 500, err.Error()).Throw() } return result } // 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 ProcessTeamInvitationCreate(process *process.Process) interface{} { process.ValidateArgNums(2) // Get user_id from session userIDStr := GetUserIDFromSession(process) teamID := process.ArgsString(0) invitationData := maps.MapStrAny(process.ArgsMap(1)) if teamID == "" { exception.New("team_id is required", 400).Throw() } // Validate required fields if _, ok := invitationData["user_id"]; !ok { exception.New("user_id is required", 400).Throw() } if _, ok := invitationData["role_id"]; !ok { exception.New("role_id is required", 400).Throw() } // Get context ctx := process.Context if ctx == nil { ctx = context.Background() } // Call business logic invitationID, err := teamInvitationCreate(ctx, userIDStr, teamID, invitationData) if err != nil { exception.New("failed to create team invitation: %s", 500, err.Error()).Throw() } return map[string]interface{}{ "invitation_id": invitationID, } } // ProcessTeamInvitationResend user.team.invitation.resend Team invitation resend processor // Args[0] string: team_id // Args[1] string: invitation_id // Return: map: {"message": "success"} func ProcessTeamInvitationResend(process *process.Process) interface{} { process.ValidateArgNums(2) // Get user_id from session userIDStr := GetUserIDFromSession(process) teamID := process.ArgsString(0) invitationID := process.ArgsString(1) if teamID == "" || invitationID == "" { exception.New("team_id and invitation_id are required", 400).Throw() } // Get context ctx := process.Context if ctx == nil { ctx = context.Background() } // Get locale from Args[2] if provided, default to "en" locale := "en" if process.NumOfArgsIs(3) { locale = process.ArgsString(2) } // Call business logic (no requestBaseURL available in process context) err := teamInvitationResend(ctx, userIDStr, teamID, invitationID, "", locale) if err != nil { exception.New("failed to resend team invitation: %s", 500, err.Error()).Throw() } return map[string]interface{}{ "message": "success", } } // ProcessTeamInvitationDelete user.team.invitation.delete Team invitation delete processor // Args[0] string: team_id // Args[1] string: invitation_id // Return: map: {"message": "success"} func ProcessTeamInvitationDelete(process *process.Process) interface{} { process.ValidateArgNums(2) // Get user_id from session userIDStr := GetUserIDFromSession(process) teamID := process.ArgsString(0) invitationID := process.ArgsString(1) if teamID == "" || invitationID == "" { exception.New("team_id and invitation_id are required", 400).Throw() } // Get context ctx := process.Context if ctx == nil { ctx = context.Background() } // Call business logic err := teamInvitationDelete(ctx, userIDStr, teamID, invitationID) if err != nil { exception.New("failed to delete team invitation: %s", 500, err.Error()).Throw() } return map[string]interface{}{ "message": "success", } } // Private Business Logic Functions (internal use only) // getAdminRoot returns the admin root path from share.App configuration // Similar to service.setupAdminRoot but without caching to avoid circular dependencies func getAdminRoot() string { adminRoot := "/yao/" if share.App.AdminRoot != "" { root := strings.TrimPrefix(share.App.AdminRoot, "/") root = strings.TrimSuffix(root, "/") adminRoot = fmt.Sprintf("/%s/", root) } return adminRoot } // getRequestBaseURL extracts the base URL from the gin context request // Returns: scheme://host (e.g., "https://example.com" or "http://localhost:8000") func getRequestBaseURL(c *gin.Context) string { if c == nil || c.Request == nil { return "" } scheme := "http" if c.Request.TLS != nil { scheme = "https" } // Check X-Forwarded-Proto header if proto := c.GetHeader("X-Forwarded-Proto"); proto != "" { scheme = proto } host := c.Request.Host if host == "" { return "" } return fmt.Sprintf("%s://%s", scheme, host) } // 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 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 // Ensure baseURL ends with / if !strings.HasSuffix(baseURL, "/") { baseURL = baseURL + "/" } return fmt.Sprintf("%s%s/%s", baseURL, invitationID, token) } // Get admin root from configuration adminRoot := getAdminRoot() // Ensure adminRoot doesn't end with / for URL construction adminRoot = strings.TrimSuffix(adminRoot, "/") // Priority 2: Use request baseURL with AdminRoot if requestBaseURL != "" { // Ensure requestBaseURL doesn't end with / requestBaseURL = strings.TrimSuffix(requestBaseURL, "/") return fmt.Sprintf("%s%s/team/invite/%s/%s", requestBaseURL, adminRoot, invitationID, token) } // Priority 3: Fallback to relative path with AdminRoot return fmt.Sprintf("%s/team/invite/%s/%s", adminRoot, invitationID, token) } // 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 { return nil, err } // Allow access if user is owner or member if !isOwner && !isMember { return nil, fmt.Errorf("access denied: user is not a member of this team") } // Get user provider instance provider, err := getUserProvider() if err != nil { return nil, fmt.Errorf("failed to get user provider: %w", err) } // Build query parameters for pending invitations param := model.QueryParam{ Wheres: []model.QueryWhere{ {Column: "team_id", Value: teamID}, {Column: "status", Value: "pending"}, // Only show pending invitations }, Orders: []model.QueryOrder{ {Column: "invited_at", Option: "desc"}, {Column: "created_at", Option: "desc"}, }, } // Add additional status filter if provided if status != "" && status != "pending" { // Replace the default pending status filter param.Wheres[1] = model.QueryWhere{ Column: "status", Value: status, } } // Get paginated invitations (pending members) result, err := provider.PaginateMembers(ctx, param, page, pagesize) if err != nil { return nil, fmt.Errorf("failed to retrieve invitations: %w", err) } return result, nil } // 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 utils.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 := utils.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 := utils.ToString(invitationData["invited_by"]) var inviterInfo *InviterInfo if inviterID != "" { inviter, err := provider.GetUser(ctx, inviterID) if err == nil { inviterInfo = &InviterInfo{ UserID: inviterID, Name: utils.ToString(inviter["name"]), Picture: utils.ToString(inviter["picture"]), } // Fallback to masked email if name is empty (for privacy protection) if inviterInfo.Name == "" { inviterInfo.Name = MaskEmail(utils.ToString(inviter["email"])) } } } // Get role label from team config using provided locale roleID := utils.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 } } } // Process team_logo if it's a wrapper - use Data URI format for direct display in img src teamLogo := utils.ToString(team["logo"]) if teamLogo != "" { teamLogo = attachment.Base64(ctx, teamLogo, true) } // Process inviter_info.picture if it's a wrapper - use Data URI format for direct display in img src if inviterInfo != nil && inviterInfo.Picture != "" { inviterInfo.Picture = attachment.Base64(ctx, inviterInfo.Picture, true) } // Build public response (exclude sensitive data like IDs) publicResponse := &PublicInvitationResponse{ InvitationID: utils.ToString(invitationData["invitation_id"]), TeamName: utils.ToString(team["name"]), TeamLogo: teamLogo, TeamDescription: utils.ToString(team["description"]), RoleLabel: roleLabel, Status: utils.ToString(invitationData["status"]), InvitedAt: utils.ToTimeString(invitationData["invited_at"]), InvitationExpiresAt: utils.ToTimeString(invitationData["invitation_expires_at"]), Message: utils.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 { return nil, err } // Allow access if user is owner or member if !isOwner && !isMember { return nil, fmt.Errorf("access denied: user is not a member of this team") } // Get user provider instance provider, err := getUserProvider() if err != nil { return nil, fmt.Errorf("failed to get user provider: %w", err) } // Get invitation details using invitation_id (business key) invitationData, err := provider.GetMemberByInvitationID(ctx, invitationID) if err != nil { return nil, fmt.Errorf("invitation not found: %w", err) } // Verify invitation belongs to this team if utils.ToString(invitationData["team_id"]) != teamID { return nil, fmt.Errorf("invitation not found in this team") } // Only return if it's a pending invitation if utils.ToString(invitationData["status"]) != "pending" { return nil, fmt.Errorf("invitation not found or no longer pending") } return invitationData, nil } // 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 teamInvitationCreate(ctx context.Context, userID, teamID string, invitationData maps.MapStrAny) (string, error) { // Remove empty string fields (should not be inserted to database) for _, field := range []string{"user_id", "email", "message", "display_name", "bio"} { if invitationData[field] == "" { delete(invitationData, field) } } // Check if user has access to the team (write permission: owner only) isOwner, _, err := checkTeamAccess(ctx, teamID, userID) if err != nil { return "", err } // Only allow access if user is owner if !isOwner { return "", fmt.Errorf("access denied: only team owner can send invitations") } // Get user provider instance provider, err := getUserProvider() if err != nil { return "", fmt.Errorf("failed to get user provider: %w", err) } // Get team information for email template team, err := provider.GetTeam(ctx, teamID) if err != nil { return "", fmt.Errorf("failed to get team information: %w", err) } teamName := utils.ToString(team["name"]) // Get inviter information for email template inviter, err := provider.GetUser(ctx, userID) if err != nil { log.Warn("Failed to get inviter information: %v", err) inviter = maps.MapStrAny{"name": "Team Admin"} } inviterName := utils.ToString(inviter["name"]) if inviterName == "" { inviterName = utils.ToString(inviter["email"]) } // Check if user is already a member or has pending invitation (if user_id is provided) var inviteeUserID string var inviteeEmail string // Get email from invitation data first inviteeEmail = utils.ToString(invitationData["email"]) if invitationData["user_id"] != nil && invitationData["user_id"] != "" { inviteeUserID = utils.ToString(invitationData["user_id"]) exists, err := provider.MemberExists(ctx, teamID, inviteeUserID) if err != nil { return "", fmt.Errorf("failed to check member existence: %w", err) } if exists { return "", fmt.Errorf("user is already a member or has a pending invitation") } // If email not provided, get it from user profile if inviteeEmail == "" { user, err := provider.GetUser(ctx, inviteeUserID) if err != nil { return "", fmt.Errorf("failed to get user information: %w", err) } inviteeEmail = utils.ToString(user["email"]) // Update invitation data with email from user profile if inviteeEmail != "" { invitationData["email"] = inviteeEmail } } } else { // For invitations without user_id (general invitation link or unregistered users) // Set user_id to nil (NULL in database) invitationData["user_id"] = nil } // Check send_email requirement early shouldSendEmail := false if settings, ok := invitationData["settings"].(*InvitationSettings); ok && settings != nil { shouldSendEmail = settings.SendEmail } else if settingsMap, ok := invitationData["settings"].(map[string]interface{}); ok { // Fallback for map format (for backward compatibility) shouldSendEmail = utils.ToBool(settingsMap["send_email"]) } // If send_email is true, email must be provided if shouldSendEmail && inviteeEmail == "" { return "", fmt.Errorf("email is required when send_email is true") } // Generate invitation token token, err := generateTeamInvitationToken() if err != nil { return "", fmt.Errorf("failed to generate invitation token: %w", err) } // Calculate expiry duration expiryDuration, err := getTeamInvitationExpiry(invitationData) if err != nil { return "", fmt.Errorf("failed to parse expiry duration: %w", err) } // Save request_base_url and settings before database operation (they will be lost in DB) requestBaseURL := utils.ToString(invitationData["request_base_url"]) savedSettings := invitationData["settings"] // Save settings reference // Set invitation-specific fields invitationData["team_id"] = teamID if invitationData["member_type"] == nil || invitationData["member_type"] == "" { invitationData["member_type"] = "user" } invitationData["status"] = "pending" invitationData["invited_by"] = userID invitationData["invited_at"] = time.Now() invitationData["invitation_token"] = token invitationData["invitation_expires_at"] = time.Now().Add(expiryDuration) invitationData["created_at"] = time.Now() invitationData["updated_at"] = time.Now() // Create invitation (as a pending member) businessMemberID, err := provider.CreateMember(ctx, invitationData) if err != nil { return "", fmt.Errorf("failed to create invitation: %w", err) } // Get the created member to retrieve the generated invitation_id createdMember, err := provider.GetMemberByMemberID(ctx, businessMemberID) if err != nil { return "", fmt.Errorf("failed to retrieve created invitation: %w", err) } // Get the generated invitation_id invitationID := utils.ToString(createdMember["invitation_id"]) // Send email if requested (shouldSendEmail was already determined earlier) if shouldSendEmail { // Use the saved requestBaseURL and settings (not from invitationData, as they were lost in DB operation) // Send email asynchronously to improve user experience go func() { bgCtx := context.Background() bgCtx = context.WithValue(bgCtx, "identity", &oauthTypes.AuthorizedInfo{UserID: userID, TeamID: teamID}) emailData := maps.MapStrAny{} for k, v := range createdMember { emailData[k] = v } emailData["request_base_url"] = requestBaseURL emailData["settings"] = savedSettings // Restore settings err := sendTeamInvitationEmail(bgCtx, inviteeEmail, inviterName, teamName, token, invitationID, emailData) if err != nil { log.Error("Failed to send invitation email: %v", err) } else { log.Info("Invitation email sent to %s for team %s (invitation_id: %s)", inviteeEmail, teamName, invitationID) } }() } return invitationID, nil } // teamInvitationResend handles the business logic for resending a team invitation func teamInvitationResend(ctx context.Context, userID, teamID, invitationID, requestBaseURL, locale string) error { // Check if user has access to the team (write permission: owner only) isOwner, _, err := checkTeamAccess(ctx, teamID, userID) if err != nil { return err } // Only allow access if user is owner if !isOwner { return fmt.Errorf("access denied: only team owner can resend invitations") } // Get user provider instance provider, err := getUserProvider() if err != nil { return fmt.Errorf("failed to get user provider: %w", err) } // Get existing invitation using invitation_id (business key) invitationData, err := provider.GetMemberByInvitationID(ctx, invitationID) if err != nil { return fmt.Errorf("invitation not found: %w", err) } // Verify invitation belongs to this team if utils.ToString(invitationData["team_id"]) != teamID { return fmt.Errorf("invitation not found in this team") } // Check if invitation is still pending if utils.ToString(invitationData["status"]) != "pending" { return fmt.Errorf("invitation is no longer pending and cannot be resent") } // Get email directly from member record's email field inviteeEmail := utils.ToString(invitationData["email"]) if inviteeEmail == "" { return fmt.Errorf("invitation has no email address, cannot resend") } // Get team information for email template team, err := provider.GetTeam(ctx, teamID) if err != nil { return fmt.Errorf("failed to get team information: %w", err) } teamName := utils.ToString(team["name"]) // Get inviter information for email template inviter, err := provider.GetUser(ctx, userID) if err != nil { log.Warn("Failed to get inviter information: %v", err) inviter = maps.MapStrAny{"name": "Team Admin"} } inviterName := utils.ToString(inviter["name"]) if inviterName == "" { inviterName = utils.ToString(inviter["email"]) } // Generate new invitation token newToken, err := generateTeamInvitationToken() if err != nil { return fmt.Errorf("failed to generate new invitation token: %w", err) } // Get team config for expiry duration teamConfig := GetTeamConfig(locale) if teamConfig == nil || teamConfig.Invite == nil { return fmt.Errorf("team configuration not found for locale: %s", locale) } // Calculate expiry duration from config or use default expiryDuration := 7 * 24 * time.Hour // Default 7 days if teamConfig.Invite.Expiry != "" { normalizedDuration, err := normalizeDuration(teamConfig.Invite.Expiry) if err != nil { log.Warn("Invalid expiry format in team config: %v, using default", err) } else { duration, err := time.ParseDuration(normalizedDuration) if err != nil { log.Warn("Failed to parse expiry duration %s: %v, using default", teamConfig.Invite.Expiry, err) } else { expiryDuration = duration } } } // Update invitation with new token and extended expiry newExpiryTime := time.Now().Add(expiryDuration) updateData := maps.MapStrAny{ "invitation_token": newToken, "invitation_expires_at": newExpiryTime, "invited_at": time.Now(), // Update invitation time "updated_at": time.Now(), } // Update invitation using invitation_id err = provider.UpdateMemberByInvitationID(ctx, invitationID, updateData) if err != nil { return fmt.Errorf("failed to update invitation: %w", err) } // Prepare invitation data for email sending invitationData["invitation_token"] = newToken invitationData["invitation_expires_at"] = newExpiryTime invitationData["request_base_url"] = requestBaseURL // Set locale in invitation settings for email template if settings, ok := invitationData["settings"].(*InvitationSettings); ok && settings != nil { settings.Locale = locale } else { // Create settings if not exists invitationData["settings"] = &InvitationSettings{ Locale: locale, } } // Send new invitation email (asynchronously) go func() { bgCtx := context.Background() bgCtx = context.WithValue(bgCtx, "identity", &oauthTypes.AuthorizedInfo{UserID: userID, TeamID: teamID}) err := sendTeamInvitationEmail(bgCtx, inviteeEmail, inviterName, teamName, newToken, invitationID, invitationData) if err != nil { log.Error("Failed to resend invitation email: %v", err) } else { log.Info("Invitation email resent to %s for team %s (invitation_id: %s)", inviteeEmail, teamName, invitationID) } }() return nil } // 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 { return err } // Only allow access if user is owner if !isOwner { return fmt.Errorf("access denied: only team owner can cancel invitations") } // Get user provider instance provider, err := getUserProvider() if err != nil { return fmt.Errorf("failed to get user provider: %w", err) } // Get existing invitation using invitation_id (business key) invitationData, err := provider.GetMemberByInvitationID(ctx, invitationID) if err != nil { return fmt.Errorf("invitation not found: %w", err) } // Verify invitation belongs to this team if utils.ToString(invitationData["team_id"]) != teamID { return fmt.Errorf("invitation not found in this team") } // Check if invitation is still pending if utils.ToString(invitationData["status"]) != "pending" { return fmt.Errorf("invitation is no longer pending and cannot be cancelled") } // Remove the pending invitation (delete the member record) err = provider.RemoveMemberByInvitationID(ctx, invitationID) if err != nil { return fmt.Errorf("failed to cancel invitation: %w", err) } return nil } // Private Helper Functions (internal use only) // 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 { return "", err } // Use URL-safe base64 encoding and remove padding return strings.TrimRight(base64.URLEncoding.EncodeToString(bytes), "="), nil } // getTeamInvitationExpiry calculates the expiry duration for an invitation // Priority: 1. Request expiry parameter, 2. Team config, 3. Default (7 days) func getTeamInvitationExpiry(invitationData maps.MapStrAny) (time.Duration, error) { // Default expiry: 7 days defaultExpiry := 7 * 24 * time.Hour // Check if expiry is provided in request expiry := utils.ToString(invitationData["expiry"]) if expiry != "" { normalizedDuration, err := normalizeDuration(expiry) if err != nil { return 0, fmt.Errorf("invalid expiry format: %w", err) } duration, err := time.ParseDuration(normalizedDuration) if err != nil { return 0, fmt.Errorf("failed to parse expiry duration: %w", err) } return duration, nil } // Get team config expiry (from global config) // Try to get locale from invitation data settings locale := "en" if settings, ok := invitationData["settings"].(*InvitationSettings); ok && settings != nil { if settings.Locale != "" { locale = settings.Locale } } else if settingsMap, ok := invitationData["settings"].(map[string]interface{}); ok { // Fallback for map format (for backward compatibility) if loc := utils.ToString(settingsMap["locale"]); loc != "" { locale = loc } } teamConfig := GetTeamConfig(locale) if teamConfig != nil && teamConfig.Invite != nil && teamConfig.Invite.Expiry != "" { normalizedDuration, err := normalizeDuration(teamConfig.Invite.Expiry) if err != nil { log.Warn("Invalid expiry format in team config: %v, using default", err) return defaultExpiry, nil } duration, err := time.ParseDuration(normalizedDuration) if err != nil { log.Warn("Failed to parse team config expiry: %v, using default", err) return defaultExpiry, nil } return duration, nil } return defaultExpiry, nil } // 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") } // Get locale from invitation data settings locale := "en" if settings, ok := invitationData["settings"].(*InvitationSettings); ok && settings != nil { if settings.Locale != "" { locale = settings.Locale } } else if settingsMap, ok := invitationData["settings"].(map[string]interface{}); ok { // Fallback for map format (for backward compatibility) if loc := utils.ToString(settingsMap["locale"]); loc != "" { locale = loc } } // Get team config for email template and channel // Note: GetTeamConfig will normalize locale internally (trim, lowercase, etc.) teamConfig := GetTeamConfig(locale) if teamConfig == nil || teamConfig.Invite == nil { return fmt.Errorf("team configuration not found for locale: %s", locale) } // Get email template ID from team config emailTemplate := "" if teamConfig.Invite.Templates != nil { if tpl, ok := teamConfig.Invite.Templates["mail"]; ok { emailTemplate = tpl } } if emailTemplate == "" { return fmt.Errorf("email template not configured in team config") } // Get channel from team config (default to "default") channel := "default" if teamConfig.Invite.Channel != "" { channel = teamConfig.Invite.Channel } // Get custom message from invitation data customMessage := utils.ToString(invitationData["message"]) // Get request base URL from invitation data (if provided) requestBaseURL := utils.ToString(invitationData["request_base_url"]) // Build invitation link using centralized helper function invitationLink := buildTeamInvitationLink(invitationID, token, teamConfig, requestBaseURL) // Get time format based on locale timeFormat := utils.GetTimeFormat(locale) // Format expires_at with locale-specific format expiresAtFormatted := utils.FormatTimeWithLocale(invitationData["invitation_expires_at"], timeFormat) // Prepare template data for messenger templateData := messengertypes.TemplateData{ "to": email, "inviter_name": inviterName, "team_name": teamName, "invitation_id": invitationID, "invitation_link": invitationLink, // Full invitation link "token": token, // Keep token for backward compatibility "message": customMessage, "role_id": utils.ToString(invitationData["role_id"]), "expires_at": expiresAtFormatted, } // Send email using messenger template err := messenger.Instance.SendT(ctx, channel, emailTemplate, templateData, messengertypes.MessageTypeEmail) if err != nil { return fmt.Errorf("failed to send invitation email: %w", err) } return nil } // convertToTeamInvitationResponse converts a map to InvitationResponse (alias for mapToTeamInvitationResponse) func convertToTeamInvitationResponse(data maps.MapStrAny, requestBaseURL string) InvitationResponse { return mapToTeamInvitationResponse(maps.MapStr(data), requestBaseURL) } // mapToTeamInvitationResponse converts a map to InvitationResponse func mapToTeamInvitationResponse(data maps.MapStr, requestBaseURL string) InvitationResponse { invitation := InvitationResponse{ ID: utils.ToInt64(data["id"]), InvitationID: utils.ToString(data["invitation_id"]), TeamID: utils.ToString(data["team_id"]), UserID: utils.ToString(data["user_id"]), MemberType: utils.ToString(data["member_type"]), RoleID: utils.ToString(data["role_id"]), Status: utils.ToString(data["status"]), InvitedBy: utils.ToString(data["invited_by"]), InvitedAt: utils.ToTimeString(data["invited_at"]), InvitationToken: utils.ToString(data["invitation_token"]), InvitationExpiresAt: utils.ToTimeString(data["invitation_expires_at"]), Message: utils.ToString(data["message"]), CreatedAt: utils.ToTimeString(data["created_at"]), UpdatedAt: utils.ToTimeString(data["updated_at"]), } // Add settings if available locale := "en" // Default locale if settings, ok := data["settings"]; ok { if invSettings, ok := settings.(*InvitationSettings); ok { invitation.Settings = invSettings if invSettings.Locale != "" { locale = invSettings.Locale } } else if settingsMap, ok := settings.(map[string]interface{}); ok { // Convert map to InvitationSettings invSettings := &InvitationSettings{ SendEmail: utils.ToBool(settingsMap["send_email"]), Locale: utils.ToString(settingsMap["locale"]), } invitation.Settings = invSettings if invSettings.Locale != "" { locale = invSettings.Locale } } } // Build invitation link if token is available if invitation.InvitationToken != "" && invitation.InvitationID != "" { teamConfig := GetTeamConfig(locale) invitation.InvitationLink = buildTeamInvitationLink(invitation.InvitationID, invitation.InvitationToken, teamConfig, requestBaseURL) } return invitation } // mapToTeamInvitationDetailResponse converts a map to InvitationDetailResponse func mapToTeamInvitationDetailResponse(data maps.MapStr, requestBaseURL string) InvitationDetailResponse { invitation := InvitationDetailResponse{ InvitationResponse: mapToTeamInvitationResponse(data, requestBaseURL), } // Add user info if available (could be joined from user table) if userInfo, ok := data["user_info"]; ok { if userInfoMap, ok := userInfo.(map[string]interface{}); ok { invitation.UserInfo = userInfoMap } } // Add team info if available (could be joined from team table) if teamInfo, ok := data["team_info"]; ok { if teamInfoMap, ok := teamInfo.(map[string]interface{}); ok { invitation.TeamInfo = teamInfoMap } } return invitation }