Enhance team invitation functionality with locale support

- Updated the invitation resend process to accept a locale parameter, allowing for locale-specific email formatting and invitation link generation.
- Modified the `teamInvitationResend` function to include locale handling, ensuring proper configuration based on the user's locale.
- Enhanced the `toString` utility function to support time formatting based on locale, improving date presentation in emails.
- Added new utility functions for locale-specific time formatting, ensuring consistent user experience across different regions.
- Expanded tests to validate the new locale handling in invitation processes, ensuring comprehensive coverage for various scenarios.
This commit is contained in:
Max 2025-10-29 09:28:55 +08:00
parent 7549a890e5
commit 481a6edbd5
4 changed files with 246 additions and 43 deletions

View file

@ -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}

View file

@ -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
}

View file

@ -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

View file

@ -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