diff --git a/openapi/tests/user/invitation_test.go b/openapi/tests/user/invitation_test.go index 590302b0..20c3c576 100644 --- a/openapi/tests/user/invitation_test.go +++ b/openapi/tests/user/invitation_test.go @@ -590,15 +590,46 @@ func TestInvitationResend(t *testing.T) { createdTeam := createTestTeam(t, serverURL, baseURL, tokenInfo.AccessToken, "Invitation Resend Test Team "+testUUID) teamID := getTeamID(createdTeam) - // Create test invitation - invitationID := createTestInvitation(t, serverURL, baseURL, tokenInfo.AccessToken, teamID, "") // Unregistered user + // Create test invitation with email (required for resend) + testEmail := fmt.Sprintf("test-resend-%s@example.com", testUUID) + invitationData := map[string]interface{}{ + "email": testEmail, + "member_type": "user", + "role_id": "user", + "message": "Test invitation for resend", + "settings": map[string]interface{}{ + "send_email": false, // Don't send email in test + }, + } + + jsonData, _ := json.Marshal(invitationData) + url := fmt.Sprintf("%s%s/user/teams/%s/invitations", serverURL, baseURL, teamID) + + req, _ := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + httpClient := &http.Client{Timeout: 10 * time.Second} + createResp, _ := httpClient.Do(req) + defer createResp.Body.Close() + + var createResult map[string]interface{} + json.NewDecoder(createResp.Body).Decode(&createResult) + invitationID := createResult["invitation_id"].(string) // Test successful resend t.Run("ResendInvitation_Success", func(t *testing.T) { url := fmt.Sprintf("%s%s/user/teams/%s/invitations/%s/resend", serverURL, baseURL, teamID, invitationID) - req, err := http.NewRequest("PUT", url, nil) + // Send locale in request body + requestBody := map[string]interface{}{ + "locale": "en", + } + jsonData, _ := json.Marshal(requestBody) + + req, err := http.NewRequest("PUT", url, bytes.NewBuffer(jsonData)) assert.NoError(t, err) + req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) client := &http.Client{Timeout: 10 * time.Second} diff --git a/openapi/user/member.go b/openapi/user/member.go index 8bbc7e6f..ec801fc4 100644 --- a/openapi/user/member.go +++ b/openapi/user/member.go @@ -81,8 +81,17 @@ func GinMemberList(c *gin.Context) { } } + // Get request base URL for invitation link generation + requestBaseURL := getRequestBaseURL(c) + + // Get locale from query parameter or default to "en" + locale := c.Query("locale") + if locale == "" { + locale = "en" + } + // Call business logic - result, err := memberList(c.Request.Context(), authInfo.UserID, teamID, &req) + result, err := memberList(c.Request.Context(), authInfo.UserID, teamID, &req, requestBaseURL, locale) if err != nil { log.Error("Failed to get team members: %v", err) // Check error type for appropriate response @@ -856,8 +865,14 @@ func ProcessMemberList(process *process.Process) interface{} { ctx = context.Background() } - // Call business logic - result, err := memberList(ctx, userIDStr, teamID, req) + // Get locale from query map if available, default to "en" + locale := "en" + if localeVal, ok := queryMap["locale"].(string); ok && localeVal != "" { + locale = localeVal + } + + // Call business logic (no requestBaseURL available in process context, use empty string) + result, err := memberList(ctx, userIDStr, teamID, req, "", locale) if err != nil { exception.New("failed to list members: %s", 500, err.Error()).Throw() } @@ -1054,7 +1069,7 @@ func ProcessMemberDelete(process *process.Process) interface{} { // Private Business Logic Functions (internal use only) // memberList handles the business logic for listing team members with advanced filtering -func memberList(ctx context.Context, userID, teamID string, req *MemberListRequest) (maps.MapStr, error) { +func memberList(ctx context.Context, userID, teamID string, req *MemberListRequest, requestBaseURL, locale 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 { @@ -1072,6 +1087,9 @@ func memberList(ctx context.Context, userID, teamID string, req *MemberListReque return nil, fmt.Errorf("failed to get user provider: %w", err) } + // Get team configuration for invitation link generation + teamConfig := GetTeamConfig(locale) + // Build query parameters param := model.QueryParam{ Wheres: []model.QueryWhere{ @@ -1192,6 +1210,23 @@ func memberList(ctx context.Context, userID, teamID string, req *MemberListReque return nil, fmt.Errorf("failed to retrieve members: %w", err) } + // Add invitation_link for pending members with token + if data, ok := result["data"].([]maps.MapStrAny); ok { + for i := range data { + member := data[i] + // Only generate invitation link for pending members with invitation_id and invitation_token + status, _ := member["status"].(string) + invitationID, _ := member["invitation_id"].(string) + invitationToken, _ := member["invitation_token"].(string) + + if status == "pending" && invitationID != "" && invitationToken != "" { + // Build invitation link using the centralized helper function + invitationLink := buildTeamInvitationLink(invitationID, invitationToken, teamConfig, requestBaseURL) + member["invitation_link"] = invitationLink + } + } + } + return result, nil } diff --git a/openapi/user/team_invitation.go b/openapi/user/team_invitation.go index 852c8ce2..c299734f 100644 --- a/openapi/user/team_invitation.go +++ b/openapi/user/team_invitation.go @@ -364,8 +364,29 @@ func GinTeamInvitationResend(c *gin.Context) { 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, getRequestBaseURL(c)) + 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 @@ -737,8 +758,14 @@ func ProcessTeamInvitationResend(process *process.Process) interface{} { 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, "") + err := teamInvitationResend(ctx, userIDStr, teamID, invitationID, "", locale) if err != nil { exception.New("failed to resend team invitation: %s", 500, err.Error()).Throw() } @@ -1184,9 +1211,10 @@ func teamInvitationCreate(ctx context.Context, userID, teamID string, invitation // Use background context for async operation bgCtx := context.Background() - // Ensure request_base_url and settings are in invitationData for email sending + // Use createdMember data (from database) for email sending + // This ensures we have the actual stored values including properly formatted timestamps emailData := maps.MapStrAny{} - for k, v := range invitationData { + for k, v := range createdMember { emailData[k] = v } emailData["request_base_url"] = requestBaseURL @@ -1205,7 +1233,7 @@ func teamInvitationCreate(ctx context.Context, userID, teamID string, invitation } // teamInvitationResend handles the business logic for resending a team invitation -func teamInvitationResend(ctx context.Context, userID, teamID, invitationID, requestBaseURL string) error { +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 { @@ -1239,6 +1267,12 @@ func teamInvitationResend(ctx context.Context, userID, teamID, invitationID, req return fmt.Errorf("invitation is no longer pending and cannot be resent") } + // Get email directly from member record's email field + inviteeEmail := 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 { @@ -1263,11 +1297,26 @@ func teamInvitationResend(ctx context.Context, userID, teamID, invitationID, req return fmt.Errorf("failed to generate new invitation token: %w", err) } - // Calculate expiry duration (use existing expiry from original invitation data) - expiryDuration, err := getTeamInvitationExpiry(invitationData) - if err != nil { - log.Warn("Failed to parse expiry duration: %v, using default", err) - expiryDuration = 7 * 24 * time.Hour + // 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 @@ -1285,36 +1334,32 @@ func teamInvitationResend(ctx context.Context, userID, teamID, invitationID, req return fmt.Errorf("failed to update invitation: %w", err) } - // Update the invitation data for email sending + // Prepare invitation data for email sending invitationData["invitation_token"] = newToken invitationData["invitation_expires_at"] = newExpiryTime invitationData["request_base_url"] = requestBaseURL - // Get email from invitation data - var inviteeEmail string - if inviteeUserID := toString(invitationData["user_id"]); inviteeUserID != "" { - // Get user email for registered user - user, err := provider.GetUser(ctx, inviteeUserID) - if err != nil { - log.Warn("Failed to get user information: %v", err) - } else { - inviteeEmail = toString(user["email"]) + // 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 if email is available (asynchronously) - if inviteeEmail != "" { - go func() { - // Use background context for async operation - bgCtx := context.Background() - 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) - } - }() - } + // Send new invitation email (asynchronously) + go func() { + // Use background context for async operation + bgCtx := context.Background() + 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 } @@ -1481,6 +1526,12 @@ func sendTeamInvitationEmail(ctx context.Context, email, inviterName, teamName, // Build invitation link using centralized helper function invitationLink := buildTeamInvitationLink(invitationID, token, teamConfig, requestBaseURL) + // Get time format based on locale + timeFormat := getTimeFormat(locale) + + // Format expires_at with locale-specific format + expiresAtFormatted := formatTimeWithLocale(invitationData["invitation_expires_at"], timeFormat) + // Prepare template data for messenger templateData := messengertypes.TemplateData{ "to": email, @@ -1491,7 +1542,7 @@ func sendTeamInvitationEmail(ctx context.Context, email, inviterName, teamName, "token": token, // Keep token for backward compatibility "message": customMessage, "role_id": toString(invitationData["role_id"]), - "expires_at": toString(invitationData["invitation_expires_at"]), + "expires_at": expiresAtFormatted, } // Send email using messenger template diff --git a/openapi/user/utils.go b/openapi/user/utils.go index c6d8d957..57c8154f 100644 --- a/openapi/user/utils.go +++ b/openapi/user/utils.go @@ -68,13 +68,21 @@ func toBool(v interface{}) bool { } // toString converts various types to string -// Supports: string, int, int64, float64, bool +// Supports: string, int, int64, float64, bool, time.Time, *time.Time +// time.Time is formatted using the optional timeFormat parameter +// If timeFormat is not provided, defaults to "2006-01-02 15:04:05" // Returns empty string for nil or unsupported types -func toString(v interface{}) string { +func toString(v interface{}, timeFormat ...string) string { if v == nil { return "" } + // Get time format (default or provided) + format := "2006-01-02 15:04:05" + if len(timeFormat) > 0 && timeFormat[0] != "" { + format = timeFormat[0] + } + switch val := v.(type) { case string: return val @@ -89,11 +97,89 @@ func toString(v interface{}) string { return "true" } return "false" + case time.Time: + return val.Format(format) + case *time.Time: + if val != nil { + return val.Format(format) + } + return "" default: return "" } } +// getTimeFormat returns the appropriate time format string for the given locale +// Returns format suitable for time.Format() +func getTimeFormat(locale string) string { + // Normalize locale to lowercase + locale = strings.ToLower(strings.TrimSpace(locale)) + + switch locale { + case "zh-cn", "zh": + // Chinese format: 2025年10月30日 08:57:51 + return "2006年01月02日 15:04:05" + case "en", "en-us", "": + // English format: October 30, 2025 08:57:51 + return "January 02, 2006 15:04:05" + default: + // Default ISO format + return "2006-01-02 15:04:05" + } +} + +// formatTimeWithLocale formats a time value (time.Time, *time.Time, or string) using the specified format +// If the input is already a string, it will parse it first and then reformat it +// Returns empty string if the value cannot be parsed +func formatTimeWithLocale(v interface{}, targetFormat string) string { + if v == nil { + return "" + } + + var t time.Time + var err error + + switch val := v.(type) { + case time.Time: + t = val + case *time.Time: + if val != nil { + t = *val + } else { + return "" + } + case string: + // Try parsing with common formats + formats := []string{ + "2006-01-02 15:04:05", + "2006-01-02T15:04:05Z", + "2006-01-02T15:04:05", + time.RFC3339, + } + for _, format := range formats { + t, err = time.Parse(format, val) + if err == nil { + break + } + } + if err != nil { + // If all parsing attempts failed, return the original string + return val + } + default: + // For unsupported types, try toString first + str := toString(v) + if str == "" { + return "" + } + // Try parsing the string + return formatTimeWithLocale(str, targetFormat) + } + + // Format with target format + return t.Format(targetFormat) +} + // toInt64 converts various types to int64 // Supports: int, int64, float64, string // Returns 0 for nil or unsupported types