Enhance messenger service with optional message type support for template sending

- Updated SendT, SendTWithProvider, SendTBatch, and SendTBatchMixed methods to accept an optional message type parameter, allowing for more flexible template usage.
- Refactored provider implementations for Mailgun, Twilio, and Mailer to accommodate the new message type parameter in their SendT and SendTBatch methods.
- Improved error handling for template retrieval and provider configuration, ensuring clearer feedback in case of issues.
- Added tests to validate the new functionality and ensure compatibility across different message types.
This commit is contained in:
Max 2025-10-09 12:11:54 +08:00
parent cf42d255d3
commit 03ecf96b51
19 changed files with 839 additions and 124 deletions

View file

@ -300,69 +300,305 @@ func (m *Service) SendWithProvider(ctx context.Context, providerName string, mes
}
// SendT sends a message using a template
func (m *Service) SendT(ctx context.Context, channel string, templateID string, data types.TemplateData) error {
// Get providers for the channel
providers := m.GetProviders(channel)
if len(providers) == 0 {
return fmt.Errorf("no providers available for channel: %s", channel)
// messageType is optional - if not specified, the first available template type will be used
func (m *Service) SendT(ctx context.Context, channel string, templateID string, data types.TemplateData, messageType ...types.MessageType) error {
m.mutex.RLock()
defer m.mutex.RUnlock()
// Determine which message type to use
var msgType types.MessageType
if len(messageType) > 0 {
// Use specified message type
msgType = messageType[0]
} else {
// Get available template types and use the first one
availableTypes := template.Global.GetAvailableTypes(templateID)
if len(availableTypes) == 0 {
return fmt.Errorf("template not found: %s", templateID)
}
// Convert TemplateType to MessageType
msgType = templateTypeToMessageType(availableTypes[0])
}
// Use the first available provider's SendT method
provider := providers[0]
return provider.SendT(ctx, templateID, data)
// Get provider for this channel and message type
providerName := m.getProviderForChannel(channel, string(msgType))
if providerName == "" {
return fmt.Errorf("no provider configured for channel %s with message type %s", channel, msgType)
}
// Get the provider
provider, exists := m.providers[providerName]
if !exists {
return fmt.Errorf("provider not found: %s", providerName)
}
// Convert MessageType back to TemplateType
templateType := messageTypeToTemplateType(msgType)
// Call provider's SendT method
return provider.SendT(ctx, templateID, templateType, data)
}
// templateTypeToMessageType converts TemplateType to MessageType
func templateTypeToMessageType(templateType types.TemplateType) types.MessageType {
switch templateType {
case types.TemplateTypeMail:
return types.MessageTypeEmail
case types.TemplateTypeSMS:
return types.MessageTypeSMS
case types.TemplateTypeWhatsApp:
return types.MessageTypeWhatsApp
default:
return ""
}
}
// messageTypeToTemplateType converts MessageType to TemplateType
func messageTypeToTemplateType(messageType types.MessageType) types.TemplateType {
switch messageType {
case types.MessageTypeEmail:
return types.TemplateTypeMail
case types.MessageTypeSMS:
return types.TemplateTypeSMS
case types.MessageTypeWhatsApp:
return types.TemplateTypeWhatsApp
default:
return ""
}
}
// SendTWithProvider sends a message using a template and specific provider
func (m *Service) SendTWithProvider(ctx context.Context, providerName string, templateID string, data types.TemplateData) error {
// messageType is optional - if not specified, the first available template type will be used
func (m *Service) SendTWithProvider(ctx context.Context, providerName string, templateID string, data types.TemplateData, messageType ...types.MessageType) error {
// Get provider
provider, exists := m.providers[providerName]
if !exists {
return fmt.Errorf("provider not found: %s", providerName)
}
// Use the provider's SendT method directly
return provider.SendT(ctx, templateID, data)
// Determine which message type to use
var msgType types.MessageType
if len(messageType) > 0 {
// Use specified message type
msgType = messageType[0]
} else {
// Get available template types and use the first one
availableTypes := template.Global.GetAvailableTypes(templateID)
if len(availableTypes) == 0 {
return fmt.Errorf("template not found: %s", templateID)
}
// Convert TemplateType to MessageType
msgType = templateTypeToMessageType(availableTypes[0])
}
// Convert MessageType to TemplateType
templateType := messageTypeToTemplateType(msgType)
// Call provider's SendT method
return provider.SendT(ctx, templateID, templateType, data)
}
// SendTBatch sends multiple messages using templates in batch
func (m *Service) SendTBatch(ctx context.Context, channel string, templateID string, dataList []types.TemplateData) error {
// Get providers for the channel
providers := m.GetProviders(channel)
if len(providers) == 0 {
return fmt.Errorf("no providers available for channel: %s", channel)
// messageType is optional - if not specified, the first available template type will be used
func (m *Service) SendTBatch(ctx context.Context, channel string, templateID string, dataList []types.TemplateData, messageType ...types.MessageType) error {
m.mutex.RLock()
defer m.mutex.RUnlock()
if len(dataList) == 0 {
return nil
}
// Use the first available provider's SendTBatch method
provider := providers[0]
return provider.SendTBatch(ctx, templateID, dataList)
// Determine which message type to use
var msgType types.MessageType
if len(messageType) > 0 {
// Use specified message type
msgType = messageType[0]
} else {
// Get available template types and use the first one
availableTypes := template.Global.GetAvailableTypes(templateID)
if len(availableTypes) == 0 {
return fmt.Errorf("template not found: %s", templateID)
}
// Convert TemplateType to MessageType
msgType = templateTypeToMessageType(availableTypes[0])
}
// Get provider for this channel and message type
providerName := m.getProviderForChannel(channel, string(msgType))
if providerName == "" {
return fmt.Errorf("no provider configured for channel %s with message type %s", channel, msgType)
}
// Get the provider
provider, exists := m.providers[providerName]
if !exists {
return fmt.Errorf("provider not found: %s", providerName)
}
// Convert MessageType to TemplateType
templateType := messageTypeToTemplateType(msgType)
// Get the template
tmpl, err := template.Global.GetTemplate(templateID, templateType)
if err != nil {
return fmt.Errorf("template not found: %w", err)
}
// Convert all template data to messages
messages := make([]*types.Message, 0, len(dataList))
for _, data := range dataList {
message, err := tmpl.ToMessage(data)
if err != nil {
return fmt.Errorf("failed to convert template to message: %w", err)
}
messages = append(messages, message)
}
// Send batch using the provider
return provider.SendBatch(ctx, messages)
}
// SendTBatchWithProvider sends multiple messages using templates and specific provider in batch
func (m *Service) SendTBatchWithProvider(ctx context.Context, providerName string, templateID string, dataList []types.TemplateData) error {
// messageType is optional - if not specified, the first available template type will be used
func (m *Service) SendTBatchWithProvider(ctx context.Context, providerName string, templateID string, dataList []types.TemplateData, messageType ...types.MessageType) error {
// Get provider
provider, exists := m.providers[providerName]
if !exists {
return fmt.Errorf("provider not found: %s", providerName)
}
// Use the provider's SendTBatch method directly
return provider.SendTBatch(ctx, templateID, dataList)
if len(dataList) == 0 {
return nil
}
// Determine which message type to use
var msgType types.MessageType
if len(messageType) > 0 {
// Use specified message type
msgType = messageType[0]
} else {
// Get available template types and use the first one
availableTypes := template.Global.GetAvailableTypes(templateID)
if len(availableTypes) == 0 {
return fmt.Errorf("template not found: %s", templateID)
}
// Convert TemplateType to MessageType
msgType = templateTypeToMessageType(availableTypes[0])
}
// Convert MessageType to TemplateType
templateType := messageTypeToTemplateType(msgType)
// Get the template
tmpl, err := template.Global.GetTemplate(templateID, templateType)
if err != nil {
return fmt.Errorf("template not found: %w", err)
}
// Convert all template data to messages
messages := make([]*types.Message, 0, len(dataList))
for _, data := range dataList {
message, err := tmpl.ToMessage(data)
if err != nil {
return fmt.Errorf("failed to convert template to message: %w", err)
}
messages = append(messages, message)
}
// Send batch using the provider
return provider.SendBatch(ctx, messages)
}
// SendTBatchMixed sends multiple messages using different templates with different data
// Each TemplateRequest can optionally specify its MessageType
func (m *Service) SendTBatchMixed(ctx context.Context, channel string, templateRequests []types.TemplateRequest) error {
// Get providers for the channel
providers := m.GetProviders(channel)
if len(providers) == 0 {
return fmt.Errorf("no providers available for channel: %s", channel)
m.mutex.RLock()
defer m.mutex.RUnlock()
if len(templateRequests) == 0 {
return nil
}
// Use the first available provider's SendTBatchMixed method
provider := providers[0]
return provider.SendTBatchMixed(ctx, templateRequests)
// Group messages by provider
providerMessages := make(map[string][]*types.Message)
// Process each template request
for _, request := range templateRequests {
// Determine message type
var msgType types.MessageType
if request.MessageType != nil {
// Use specified message type
msgType = *request.MessageType
} else {
// Get available template types and use the first one
availableTypes := template.Global.GetAvailableTypes(request.TemplateID)
if len(availableTypes) == 0 {
return fmt.Errorf("template not found: %s", request.TemplateID)
}
// Convert TemplateType to MessageType
msgType = templateTypeToMessageType(availableTypes[0])
}
// Get provider for this channel and message type
providerName := m.getProviderForChannel(channel, string(msgType))
if providerName == "" {
return fmt.Errorf("no provider configured for channel %s with message type %s", channel, msgType)
}
// Verify provider exists
if _, exists := m.providers[providerName]; !exists {
return fmt.Errorf("provider not found: %s", providerName)
}
// Convert MessageType to TemplateType
templateType := messageTypeToTemplateType(msgType)
// Get the template
tmpl, err := template.Global.GetTemplate(request.TemplateID, templateType)
if err != nil {
return fmt.Errorf("template %s not found: %w", request.TemplateID, err)
}
// Convert template to message
message, err := tmpl.ToMessage(request.Data)
if err != nil {
return fmt.Errorf("failed to convert template %s to message: %w", request.TemplateID, err)
}
// Add to provider's message list
providerMessages[providerName] = append(providerMessages[providerName], message)
}
// Send batches to each provider
var errors []string
for providerName, messages := range providerMessages {
// Check if context is cancelled
select {
case <-ctx.Done():
return fmt.Errorf("batch send cancelled: %w", ctx.Err())
default:
}
provider, exists := m.providers[providerName]
if !exists {
errors = append(errors, fmt.Sprintf("provider not found: %s", providerName))
continue
}
err := provider.SendBatch(ctx, messages)
if err != nil {
errors = append(errors, fmt.Sprintf("provider %s: %v", providerName, err))
}
}
if len(errors) > 0 {
return fmt.Errorf("batch send errors: %s", strings.Join(errors, "; "))
}
return nil
}
// SendTBatchMixedWithProvider sends multiple messages using different templates with different data and specific provider
// Each TemplateRequest can optionally specify its MessageType
func (m *Service) SendTBatchMixedWithProvider(ctx context.Context, providerName string, templateRequests []types.TemplateRequest) error {
// Get provider
provider, exists := m.providers[providerName]
@ -370,8 +606,49 @@ func (m *Service) SendTBatchMixedWithProvider(ctx context.Context, providerName
return fmt.Errorf("provider not found: %s", providerName)
}
// Use the provider's SendTBatchMixed method directly
return provider.SendTBatchMixed(ctx, templateRequests)
if len(templateRequests) == 0 {
return nil
}
// Convert all template requests to messages
messages := make([]*types.Message, 0, len(templateRequests))
for _, request := range templateRequests {
// Determine message type
var msgType types.MessageType
if request.MessageType != nil {
// Use specified message type
msgType = *request.MessageType
} else {
// Get available template types and use the first one
availableTypes := template.Global.GetAvailableTypes(request.TemplateID)
if len(availableTypes) == 0 {
return fmt.Errorf("template not found: %s", request.TemplateID)
}
// Convert TemplateType to MessageType
msgType = templateTypeToMessageType(availableTypes[0])
}
// Convert MessageType to TemplateType
templateType := messageTypeToTemplateType(msgType)
// Get the template
tmpl, err := template.Global.GetTemplate(request.TemplateID, templateType)
if err != nil {
return fmt.Errorf("template %s not found: %w", request.TemplateID, err)
}
// Convert template to message
message, err := tmpl.ToMessage(request.Data)
if err != nil {
return fmt.Errorf("failed to convert template %s to message: %w", request.TemplateID, err)
}
messages = append(messages, message)
}
// Send batch using the provider
return provider.SendBatch(ctx, messages)
}
// SendBatch sends multiple messages in batch

View file

@ -0,0 +1,252 @@
package messenger
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/messenger/template"
"github.com/yaoapp/yao/messenger/types"
"github.com/yaoapp/yao/test"
)
// TestSendT_TemplateTypeSelection tests that SendT correctly selects template type and provider based on channel configuration
func TestSendT_TemplateTypeSelection(t *testing.T) {
// Prepare test environment
test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION")
defer test.Clean()
// Load messenger
err := Load(config.Conf)
require.NoError(t, err, "Load messenger should succeed")
// Load templates
err = template.LoadTemplates()
require.NoError(t, err, "Load templates should succeed")
service, ok := Instance.(*Service)
require.True(t, ok, "Instance should be of type *Service")
// Test 1: Get available types for a template
availableTypes := template.Global.GetAvailableTypes("en.invite_member")
t.Logf("Available types for en.invite_member: %v", availableTypes)
// Test 2: SendT without specifying messageType (should use first available)
ctx := context.Background()
data := types.TemplateData{
"to": []string{"test@example.com"},
"team_name": "Test Team",
"inviter_name": "Test User",
"invite_link": "https://example.com/invite/test",
}
// Note: This will fail with actual sending due to test credentials, but we're testing the logic
err = service.SendT(ctx, "default", "en.invite_member", data)
if err != nil {
t.Logf("SendT failed (expected with test credentials): %v", err)
// Should not be template-not-found or provider-not-found error
assert.NotContains(t, err.Error(), "template not found", "Should not be template error")
}
// Test 3: SendT with explicit messageType
err = service.SendT(ctx, "default", "en.invite_member", data, types.MessageTypeEmail)
if err != nil {
t.Logf("SendT with explicit type failed (expected with test credentials): %v", err)
assert.NotContains(t, err.Error(), "template not found", "Should not be template error")
}
}
// TestGetProviderForChannel tests the provider selection logic
func TestGetProviderForChannel(t *testing.T) {
// Prepare test environment
test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION")
defer test.Clean()
// Load messenger
err := Load(config.Conf)
require.NoError(t, err, "Load messenger should succeed")
service, ok := Instance.(*Service)
require.True(t, ok, "Instance should be of type *Service")
// Test provider selection for different channels and message types
tests := []struct {
channel string
messageType string
expected string // Expected provider name
}{
{"default", "email", "primary"},
{"default", "sms", "unified"},
{"default", "whatsapp", "unified"},
{"promotions", "email", "marketing"},
{"alerts", "email", "reliable"},
{"notifications", "email", "primary"},
}
for _, tt := range tests {
t.Run(tt.channel+"_"+tt.messageType, func(t *testing.T) {
providerName := service.getProviderForChannel(tt.channel, tt.messageType)
assert.Equal(t, tt.expected, providerName,
"Channel %s with message type %s should use provider %s",
tt.channel, tt.messageType, tt.expected)
})
}
}
// TestTemplateTypeConversion tests the conversion between MessageType and TemplateType
func TestTemplateTypeConversion(t *testing.T) {
// Test templateTypeToMessageType
tests := []struct {
templateType types.TemplateType
expected types.MessageType
}{
{types.TemplateTypeMail, types.MessageTypeEmail},
{types.TemplateTypeSMS, types.MessageTypeSMS},
{types.TemplateTypeWhatsApp, types.MessageTypeWhatsApp},
}
for _, tt := range tests {
result := templateTypeToMessageType(tt.templateType)
assert.Equal(t, tt.expected, result,
"TemplateType %s should convert to MessageType %s",
tt.templateType, tt.expected)
}
// Test messageTypeToTemplateType
reverseTests := []struct {
messageType types.MessageType
expected types.TemplateType
}{
{types.MessageTypeEmail, types.TemplateTypeMail},
{types.MessageTypeSMS, types.TemplateTypeSMS},
{types.MessageTypeWhatsApp, types.TemplateTypeWhatsApp},
}
for _, tt := range reverseTests {
result := messageTypeToTemplateType(tt.messageType)
assert.Equal(t, tt.expected, result,
"MessageType %s should convert to TemplateType %s",
tt.messageType, tt.expected)
}
}
// TestSendTBatch tests the batch sending with template type selection
func TestSendTBatch(t *testing.T) {
// Prepare test environment
test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION")
defer test.Clean()
// Load messenger
err := Load(config.Conf)
require.NoError(t, err, "Load messenger should succeed")
// Load templates
err = template.LoadTemplates()
require.NoError(t, err, "Load templates should succeed")
service, ok := Instance.(*Service)
require.True(t, ok, "Instance should be of type *Service")
// Test batch sending
ctx := context.Background()
dataList := []types.TemplateData{
{
"to": []string{"user1@example.com"},
"team_name": "Test Team",
"inviter_name": "Test User",
"invite_link": "https://example.com/invite/test1",
},
{
"to": []string{"user2@example.com"},
"team_name": "Test Team",
"inviter_name": "Test User",
"invite_link": "https://example.com/invite/test2",
},
}
// Test without explicit messageType
err = service.SendTBatch(ctx, "default", "en.invite_member", dataList)
if err != nil {
t.Logf("SendTBatch failed (expected with test credentials): %v", err)
assert.NotContains(t, err.Error(), "template not found", "Should not be template error")
}
// Test with explicit messageType
err = service.SendTBatch(ctx, "default", "en.invite_member", dataList, types.MessageTypeEmail)
if err != nil {
t.Logf("SendTBatch with explicit type failed (expected with test credentials): %v", err)
assert.NotContains(t, err.Error(), "template not found", "Should not be template error")
}
}
// TestSendTBatchMixed tests mixed template batch sending
func TestSendTBatchMixed(t *testing.T) {
// Prepare test environment
test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION")
defer test.Clean()
// Load messenger
err := Load(config.Conf)
require.NoError(t, err, "Load messenger should succeed")
// Load templates
err = template.LoadTemplates()
require.NoError(t, err, "Load templates should succeed")
service, ok := Instance.(*Service)
require.True(t, ok, "Instance should be of type *Service")
// Test mixed batch sending
ctx := context.Background()
// Test without specifying MessageType in requests
requests := []types.TemplateRequest{
{
TemplateID: "en.invite_member",
Data: types.TemplateData{
"to": []string{"user1@example.com"},
"team_name": "Test Team",
"inviter_name": "Test User",
"invite_link": "https://example.com/invite/test1",
},
},
{
TemplateID: "en.invite_member",
Data: types.TemplateData{
"to": []string{"user2@example.com"},
"team_name": "Test Team",
"inviter_name": "Test User",
"invite_link": "https://example.com/invite/test2",
},
},
}
err = service.SendTBatchMixed(ctx, "default", requests)
if err != nil {
t.Logf("SendTBatchMixed failed (expected with test credentials): %v", err)
assert.NotContains(t, err.Error(), "template not found", "Should not be template error")
}
// Test with explicit MessageType in requests
emailType := types.MessageTypeEmail
requestsWithType := []types.TemplateRequest{
{
TemplateID: "en.invite_member",
MessageType: &emailType,
Data: types.TemplateData{
"to": []string{"user1@example.com"},
"team_name": "Test Team",
"inviter_name": "Test User",
"invite_link": "https://example.com/invite/test1",
},
},
}
err = service.SendTBatchMixed(ctx, "default", requestsWithType)
if err != nil {
t.Logf("SendTBatchMixed with explicit type failed (expected with test credentials): %v", err)
assert.NotContains(t, err.Error(), "template not found", "Should not be template error")
}
}

View file

@ -210,9 +210,9 @@ func (p *Provider) SendBatch(ctx context.Context, messages []*types.Message) err
}
// SendT sends a message using a template
func (p *Provider) SendT(ctx context.Context, templateID string, data types.TemplateData) error {
// Get template from provider's template manager (mailer supports mail templates)
template, err := p.getTemplate(templateID, types.TemplateTypeMail)
func (p *Provider) SendT(ctx context.Context, templateID string, templateType types.TemplateType, data types.TemplateData) error {
// Get template from provider's template manager with specified type
template, err := p.getTemplate(templateID, templateType)
if err != nil {
return fmt.Errorf("template not found: %w", err)
}
@ -228,9 +228,9 @@ func (p *Provider) SendT(ctx context.Context, templateID string, data types.Temp
}
// SendTBatch sends multiple messages using templates in batch
func (p *Provider) SendTBatch(ctx context.Context, templateID string, dataList []types.TemplateData) error {
// Get template from provider's template manager (mailer supports mail templates)
template, err := p.getTemplate(templateID, types.TemplateTypeMail)
func (p *Provider) SendTBatch(ctx context.Context, templateID string, templateType types.TemplateType, dataList []types.TemplateData) error {
// Get template from provider's template manager with specified type
template, err := p.getTemplate(templateID, templateType)
if err != nil {
return fmt.Errorf("template not found: %w", err)
}

View file

@ -50,7 +50,7 @@ func TestSendTBatch_Success(t *testing.T) {
}
// Test SendTBatch - should fail because template manager is nil
err = provider.SendTBatch(context.Background(), "en.invite_member", dataList)
err = provider.SendTBatch(context.Background(), "en.invite_member", types.TemplateTypeMail, dataList)
assert.Error(t, err)
assert.Contains(t, err.Error(), "template manager not available")
}
@ -95,7 +95,7 @@ func TestSendTBatch_ContextTimeout(t *testing.T) {
time.Sleep(2 * time.Nanosecond)
// Test SendTBatch with expired context
err = provider.SendTBatch(ctx, "en.invite_member", dataList)
err = provider.SendTBatch(ctx, "en.invite_member", types.TemplateTypeMail, dataList)
assert.Error(t, err)
// Error could be either "template manager not available" or "context deadline exceeded"
t.Logf("Error: %v", err)

View file

@ -48,7 +48,7 @@ func TestSendT_TemplateNotImplemented(t *testing.T) {
}
// Test that SendT returns "template not found" error (template system is working)
err = provider.SendT(ctx, "en.invite_member.mail", templateData)
err = provider.SendT(ctx, "en.invite_member.mail", types.TemplateTypeMail, templateData)
assert.Error(t, err)
assert.Contains(t, err.Error(), "template not found")
}
@ -88,7 +88,7 @@ func TestSendT_ContextTimeout(t *testing.T) {
"invite_link": "https://example.com/invite/123",
}
err = provider.SendT(ctx, "en.invite_member.mail", templateData)
err = provider.SendT(ctx, "en.invite_member.mail", types.TemplateTypeMail, templateData)
assert.Error(t, err)
// Verify it's a context timeout error or not implemented error
@ -173,6 +173,6 @@ func BenchmarkSendT(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
// This will return "not implemented" error, but we're measuring the overhead
_ = provider.SendT(ctx, "en.invite_member.mail", templateData)
_ = provider.SendT(ctx, "en.invite_member.mail", types.TemplateTypeMail, templateData)
}
}

View file

@ -94,9 +94,9 @@ func (p *Provider) SendBatch(ctx context.Context, messages []*types.Message) err
}
// SendT sends a message using a template
func (p *Provider) SendT(ctx context.Context, templateID string, data types.TemplateData) error {
// Get template from provider's template manager
template, err := p.getTemplate(templateID, types.TemplateTypeMail) // Mailgun supports email
func (p *Provider) SendT(ctx context.Context, templateID string, templateType types.TemplateType, data types.TemplateData) error {
// Get template from provider's template manager with specified type
template, err := p.getTemplate(templateID, templateType)
if err != nil {
return fmt.Errorf("template not found: %w", err)
}
@ -112,9 +112,9 @@ func (p *Provider) SendT(ctx context.Context, templateID string, data types.Temp
}
// SendTBatch sends multiple messages using templates in batch
func (p *Provider) SendTBatch(ctx context.Context, templateID string, dataList []types.TemplateData) error {
// Get template from provider's template manager
template, err := p.getTemplate(templateID, types.TemplateTypeMail) // Mailgun supports email
func (p *Provider) SendTBatch(ctx context.Context, templateID string, templateType types.TemplateType, dataList []types.TemplateData) error {
// Get template from provider's template manager with specified type
template, err := p.getTemplate(templateID, templateType)
if err != nil {
return fmt.Errorf("template not found: %w", err)
}

View file

@ -46,7 +46,7 @@ func TestSendTBatch_Success(t *testing.T) {
}
// Test SendTBatch - should fail because template manager is nil
err = provider.SendTBatch(context.Background(), "en.invite_member", dataList)
err = provider.SendTBatch(context.Background(), "en.invite_member", types.TemplateTypeMail, dataList)
assert.Error(t, err)
assert.Contains(t, err.Error(), "template manager not available")
}
@ -87,7 +87,7 @@ func TestSendTBatch_ContextTimeout(t *testing.T) {
time.Sleep(2 * time.Nanosecond)
// Test SendTBatch with expired context
err = provider.SendTBatch(ctx, "en.invite_member", dataList)
err = provider.SendTBatch(ctx, "en.invite_member", types.TemplateTypeMail, dataList)
assert.Error(t, err)
// Error could be either "template manager not available" or "context deadline exceeded"
t.Logf("Error: %v", err)

View file

@ -41,7 +41,7 @@ func TestSendT_TemplateNotImplemented(t *testing.T) {
}
// Test that SendT returns "template manager not available" error
err = provider.SendT(ctx, "en.invite_member", templateData)
err = provider.SendT(ctx, "en.invite_member", types.TemplateTypeMail, templateData)
assert.Error(t, err)
assert.Contains(t, err.Error(), "template manager not available")
}
@ -80,7 +80,7 @@ func TestSendT_ContextTimeout(t *testing.T) {
}
// Test that SendT handles context timeout
err = provider.SendT(ctx, "en.invite_member", templateData)
err = provider.SendT(ctx, "en.invite_member", types.TemplateTypeMail, templateData)
assert.Error(t, err)
// Verify it's a context timeout error or template manager error

View file

@ -126,9 +126,9 @@ func (p *Provider) SendBatch(ctx context.Context, messages []*types.Message) err
}
// SendT sends a message using a template
func (p *Provider) SendT(ctx context.Context, templateID string, data types.TemplateData) error {
// Get template from provider's template manager
template, err := p.getTemplate(templateID, types.TemplateTypeSMS) // Twilio primarily supports SMS
func (p *Provider) SendT(ctx context.Context, templateID string, templateType types.TemplateType, data types.TemplateData) error {
// Get template from provider's template manager with specified type
template, err := p.getTemplate(templateID, templateType)
if err != nil {
return fmt.Errorf("template not found: %w", err)
}
@ -144,9 +144,9 @@ func (p *Provider) SendT(ctx context.Context, templateID string, data types.Temp
}
// SendTBatch sends multiple messages using templates in batch
func (p *Provider) SendTBatch(ctx context.Context, templateID string, dataList []types.TemplateData) error {
// Get template from provider's template manager
template, err := p.getTemplate(templateID, types.TemplateTypeSMS) // Twilio primarily supports SMS
func (p *Provider) SendTBatch(ctx context.Context, templateID string, templateType types.TemplateType, dataList []types.TemplateData) error {
// Get template from provider's template manager with specified type
template, err := p.getTemplate(templateID, templateType)
if err != nil {
return fmt.Errorf("template not found: %w", err)
}

View file

@ -46,7 +46,7 @@ func TestSendTBatch_Success(t *testing.T) {
}
// Test SendTBatch - should fail because template manager is nil
err = provider.SendTBatch(context.Background(), "en.invite_member", dataList)
err = provider.SendTBatch(context.Background(), "en.invite_member", types.TemplateTypeSMS, dataList)
assert.Error(t, err)
assert.Contains(t, err.Error(), "template manager not available")
}
@ -87,7 +87,7 @@ func TestSendTBatch_ContextTimeout(t *testing.T) {
time.Sleep(2 * time.Nanosecond)
// Test SendTBatch with expired context
err = provider.SendTBatch(ctx, "en.invite_member", dataList)
err = provider.SendTBatch(ctx, "en.invite_member", types.TemplateTypeSMS, dataList)
assert.Error(t, err)
// Error could be either "template manager not available" or "context deadline exceeded"
t.Logf("Error: %v", err)

View file

@ -40,7 +40,7 @@ func TestSendT_TemplateNotImplemented(t *testing.T) {
}
// Test that SendT returns "template manager not available" error
err = provider.SendT(ctx, "en.invite_member", templateData)
err = provider.SendT(ctx, "en.invite_member", types.TemplateTypeSMS, templateData)
assert.Error(t, err)
assert.Contains(t, err.Error(), "template manager not available")
}
@ -78,7 +78,7 @@ func TestSendT_ContextTimeout(t *testing.T) {
}
// Test that SendT handles context timeout
err = provider.SendT(ctx, "en.invite_member", templateData)
err = provider.SendT(ctx, "en.invite_member", types.TemplateTypeSMS, templateData)
assert.Error(t, err)
// Verify it's a context timeout error or template manager error

View file

@ -52,6 +52,31 @@ func (m *Manager) GetAllTemplates() map[string]map[types.TemplateType]*types.Tem
return result
}
// GetAvailableTypes returns all available template types for a given templateID
// Returns types in a consistent order: mail, sms, whatsapp
func (m *Manager) GetAvailableTypes(templateID string) []types.TemplateType {
m.mutex.RLock()
defer m.mutex.RUnlock()
if templates, exists := m.templates[templateID]; exists {
// Return in preferred order
var result []types.TemplateType
preferredOrder := []types.TemplateType{
types.TemplateTypeMail,
types.TemplateTypeSMS,
types.TemplateTypeWhatsApp,
}
for _, templateType := range preferredOrder {
if _, exists := templates[templateType]; exists {
result = append(result, templateType)
}
}
return result
}
return []types.TemplateType{}
}
// ReloadTemplates reloads all templates from disk
func (m *Manager) ReloadTemplates() error {
m.mutex.Lock()
@ -224,16 +249,16 @@ func parseMailTemplate(content string) (subject, body, html string, err error) {
// Extract subject from <Subject> tag
subject = strings.TrimSpace(doc.Find("Subject").Text())
// Extract body content from <body> tag
bodySelection := doc.Find("body")
// Extract body content from <Content> tag (using custom tag to avoid HTML parser auto-conversion)
bodySelection := doc.Find("Content")
if bodySelection.Length() == 0 {
return "", "", "", fmt.Errorf("no <body> tag found in mail template")
return "", "", "", fmt.Errorf("no <Content> tag found in mail template")
}
// Get the HTML content of the body tag
// Get the HTML content of the Content tag
body, err = bodySelection.Html()
if err != nil {
return "", "", "", fmt.Errorf("failed to extract body HTML: %w", err)
return "", "", "", fmt.Errorf("failed to extract content HTML: %w", err)
}
body = strings.TrimSpace(body)

View file

@ -13,13 +13,16 @@ type Provider interface {
// SendBatch sends multiple messages in batch
SendBatch(ctx context.Context, messages []*Message) error
// SendT sends a message using a template (optional - providers may return "not implemented" error)
SendT(ctx context.Context, templateID string, data TemplateData) error
// SendT sends a message using a template with specified type
// templateType specifies which template variant to use (mail, sms, whatsapp)
SendT(ctx context.Context, templateID string, templateType TemplateType, data TemplateData) error
// SendTBatch sends multiple messages using the same template with different data (optional - providers may return "not implemented" error)
SendTBatch(ctx context.Context, templateID string, dataList []TemplateData) error
// SendTBatch sends multiple messages using the same template with different data
// templateType specifies which template variant to use (mail, sms, whatsapp)
SendTBatch(ctx context.Context, templateID string, templateType TemplateType, dataList []TemplateData) error
// SendTBatchMixed sends multiple messages using different templates with different data (optional - providers may return "not implemented" error)
// SendTBatchMixed sends multiple messages using different templates with different data
// Each TemplateRequest can optionally specify its own MessageType
SendTBatchMixed(ctx context.Context, templateRequests []TemplateRequest) error
// TriggerWebhook processes webhook requests and converts to Message
@ -50,16 +53,20 @@ type Messenger interface {
SendWithProvider(ctx context.Context, providerName string, message *Message) error
// SendT sends a message using a template
SendT(ctx context.Context, channel string, templateID string, data TemplateData) error
// messageType is optional - if not specified, the first available template type will be used
SendT(ctx context.Context, channel string, templateID string, data TemplateData, messageType ...MessageType) error
// SendTWithProvider sends a message using a template and specific provider
SendTWithProvider(ctx context.Context, providerName string, templateID string, data TemplateData) error
// messageType is optional - if not specified, the first available template type will be used
SendTWithProvider(ctx context.Context, providerName string, templateID string, data TemplateData, messageType ...MessageType) error
// SendTBatch sends multiple messages using the same template with different data
SendTBatch(ctx context.Context, channel string, templateID string, dataList []TemplateData) error
// messageType is optional - if not specified, the first available template type will be used
SendTBatch(ctx context.Context, channel string, templateID string, dataList []TemplateData, messageType ...MessageType) error
// SendTBatchWithProvider sends multiple messages using the same template with different data and specific provider
SendTBatchWithProvider(ctx context.Context, providerName string, templateID string, dataList []TemplateData) error
// messageType is optional - if not specified, the first available template type will be used
SendTBatchWithProvider(ctx context.Context, providerName string, templateID string, dataList []TemplateData, messageType ...MessageType) error
// SendTBatchMixed sends multiple messages using different templates with different data
SendTBatchMixed(ctx context.Context, channel string, templateRequests []TemplateRequest) error

View file

@ -15,6 +15,20 @@ const (
TemplateTypeWhatsApp TemplateType = "whatsapp"
)
// templateTypeToMessageType converts TemplateType to MessageType
func templateTypeToMessageType(templateType TemplateType) MessageType {
switch templateType {
case TemplateTypeMail:
return MessageTypeEmail
case TemplateTypeSMS:
return MessageTypeSMS
case TemplateTypeWhatsApp:
return MessageTypeWhatsApp
default:
return ""
}
}
// Template represents a message template
type Template struct {
ID string `json:"id"`
@ -71,9 +85,12 @@ func (t *Template) ToMessage(data TemplateData) (*Message, error) {
return nil, fmt.Errorf("template data must include 'to' field with recipients")
}
// Convert TemplateType to MessageType
messageType := templateTypeToMessageType(t.Type)
// Create message
message := &Message{
Type: MessageType(t.Type),
Type: messageType,
Subject: subject,
Body: body,
HTML: html,

View file

@ -128,6 +128,7 @@ type Features struct {
// TemplateRequest represents a request to send a message using a specific template
type TemplateRequest struct {
TemplateID string `json:"template_id"`
Data TemplateData `json:"data"`
TemplateID string `json:"template_id"`
Data TemplateData `json:"data"`
MessageType *MessageType `json:"message_type,omitempty"` // Optional: if not specified, will use first available template type
}

View file

@ -419,7 +419,7 @@ func GetTeamConfig(locale string) *TeamConfig {
// Normalize language code to lowercase
if locale != "" {
locale = strings.ToLower(locale)
locale = strings.TrimSpace(strings.ToLower(locale))
}
// Try to get the specific locale configuration
@ -427,7 +427,12 @@ func GetTeamConfig(locale string) *TeamConfig {
return config
}
// If no specific locale, try to get any available configuration
// If no specific locale, try to get "en" as default
if config, exists := teamConfigs["en"]; exists {
return config
}
// If "en" is not available, try to get any available configuration
for _, config := range teamConfigs {
return config
}

View file

@ -20,6 +20,7 @@ import (
messengertypes "github.com/yaoapp/yao/messenger/types"
"github.com/yaoapp/yao/openapi/oauth"
"github.com/yaoapp/yao/openapi/response"
"github.com/yaoapp/yao/share"
)
// Team Invitation Management Handlers
@ -118,6 +119,9 @@ func GinInvitationGet(c *gin.Context) {
return
}
// Extract base URL from request
requestBaseURL := getRequestBaseURL(c)
// Call business logic
invitationData, err := invitationGet(c.Request.Context(), authInfo.UserID, teamID, invitationID)
if err != nil {
@ -145,8 +149,8 @@ func GinInvitationGet(c *gin.Context) {
return
}
// Convert to response format
invitation := mapToInvitationDetailResponse(invitationData)
// Convert to response format (with requestBaseURL for building full invitation link)
invitation := mapToInvitationDetailResponse(invitationData, requestBaseURL)
c.JSON(http.StatusOK, invitation)
}
@ -184,14 +188,18 @@ func GinInvitationCreate(c *gin.Context) {
return
}
// Extract base URL from request
requestBaseURL := getRequestBaseURL(c)
// Prepare invitation data
invitationData := maps.MapStrAny{
"user_id": req.UserID,
"email": req.Email,
"member_type": req.MemberType,
"role_id": req.RoleID,
"message": req.Message,
"expiry": req.Expiry,
"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
@ -205,6 +213,11 @@ func GinInvitationCreate(c *gin.Context) {
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
@ -258,8 +271,8 @@ func GinInvitationCreate(c *gin.Context) {
return
}
// Convert to InvitationResponse
invitationResp := convertToInvitationResponse(invitation)
// Convert to InvitationResponse (with requestBaseURL for building full invitation link)
invitationResp := convertToInvitationResponse(invitation, requestBaseURL)
// Return created invitation with full details (including token)
c.JSON(http.StatusCreated, invitationResp)
@ -290,7 +303,7 @@ func GinInvitationResend(c *gin.Context) {
}
// Call business logic
err := invitationResend(c.Request.Context(), authInfo.UserID, teamID, invitationID)
err := invitationResend(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
@ -526,8 +539,8 @@ func ProcessInvitationResend(process *process.Process) interface{} {
ctx = context.Background()
}
// Call business logic
err := invitationResend(ctx, userIDStr, teamID, invitationID)
// Call business logic (no requestBaseURL available in process context)
err := invitationResend(ctx, userIDStr, teamID, invitationID, "")
if err != nil {
exception.New("failed to resend invitation: %s", 500, err.Error()).Throw()
}
@ -573,6 +586,75 @@ func ProcessInvitationDelete(process *process.Process) interface{} {
// 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)
}
// buildInvitationLink 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 {
// 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)
}
// 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) {
// Check if user has access to the team (read permission: owner or member)
@ -762,6 +844,10 @@ func invitationCreate(ctx context.Context, userID, teamID string, invitationData
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 := 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"] == "" {
@ -792,21 +878,34 @@ func invitationCreate(ctx context.Context, userID, teamID string, invitationData
// Send email if requested (shouldSendEmail was already determined earlier)
if shouldSendEmail {
err = sendInvitationEmail(ctx, inviteeEmail, inviterName, teamName, token, invitationID, invitationData)
if err != nil {
log.Error("Failed to send invitation email: %v", err)
// Don't fail the invitation creation if email fails
// The invitation link can still be shared manually
} else {
log.Info("Invitation email sent to %s for team %s (invitation_id: %s)", inviteeEmail, teamName, invitationID)
}
// 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() {
// Use background context for async operation
bgCtx := context.Background()
// Ensure request_base_url and settings are in invitationData for email sending
emailData := maps.MapStrAny{}
for k, v := range invitationData {
emailData[k] = v
}
emailData["request_base_url"] = requestBaseURL
emailData["settings"] = savedSettings // Restore settings
err := sendInvitationEmail(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
}
// invitationResend handles the business logic for resending a team invitation
func invitationResend(ctx context.Context, userID, teamID, invitationID string) error {
func invitationResend(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 {
@ -889,6 +988,7 @@ func invitationResend(ctx context.Context, userID, teamID, invitationID string)
// Update the 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
@ -902,13 +1002,18 @@ func invitationResend(ctx context.Context, userID, teamID, invitationID string)
}
}
// Send new invitation email if email is available
// Send new invitation email if email is available (asynchronously)
if inviteeEmail != "" {
err = sendInvitationEmail(ctx, inviteeEmail, inviterName, teamName, newToken, invitationID, invitationData)
if err != nil {
log.Error("Failed to resend invitation email: %v", err)
// Don't fail the resend if email fails
}
go func() {
// Use background context for async operation
bgCtx := context.Background()
err := sendInvitationEmail(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
@ -1044,6 +1149,7 @@ func sendInvitationEmail(ctx context.Context, email, inviterName, teamName, toke
}
// 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)
@ -1069,35 +1175,41 @@ func sendInvitationEmail(ctx context.Context, email, inviterName, teamName, toke
// Get custom message from invitation data
customMessage := toString(invitationData["message"])
// Get request base URL from invitation data (if provided)
requestBaseURL := toString(invitationData["request_base_url"])
// Build invitation link using centralized helper function
invitationLink := buildInvitationLink(invitationID, token, teamConfig, requestBaseURL)
// Prepare template data for messenger
templateData := messengertypes.TemplateData{
"to": email,
"inviter_name": inviterName,
"team_name": teamName,
"invitation_id": invitationID,
"token": token,
"message": customMessage,
"role_id": toString(invitationData["role_id"]),
"expires_at": toString(invitationData["invitation_expires_at"]),
"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": toString(invitationData["role_id"]),
"expires_at": toString(invitationData["invitation_expires_at"]),
}
// Send email using messenger template
err := messenger.Instance.SendT(ctx, channel, emailTemplate, templateData)
err := messenger.Instance.SendT(ctx, channel, emailTemplate, templateData, messengertypes.MessageTypeEmail)
if err != nil {
return fmt.Errorf("failed to send invitation email: %w", err)
}
log.Info("Invitation email sent to %s for team %s (invitation_id: %s)", email, teamName, invitationID)
return nil
}
// convertToInvitationResponse converts a map to InvitationResponse (alias for mapToInvitationResponse)
func convertToInvitationResponse(data maps.MapStrAny) InvitationResponse {
return mapToInvitationResponse(maps.MapStr(data))
func convertToInvitationResponse(data maps.MapStrAny, requestBaseURL string) InvitationResponse {
return mapToInvitationResponse(maps.MapStr(data), requestBaseURL)
}
// mapToInvitationResponse converts a map to InvitationResponse
func mapToInvitationResponse(data maps.MapStr) InvitationResponse {
func mapToInvitationResponse(data maps.MapStr, requestBaseURL string) InvitationResponse {
invitation := InvitationResponse{
ID: toInt64(data["id"]),
InvitationID: toString(data["invitation_id"]),
@ -1116,9 +1228,13 @@ func mapToInvitationResponse(data maps.MapStr) InvitationResponse {
}
// 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{
@ -1126,16 +1242,25 @@ func mapToInvitationResponse(data maps.MapStr) InvitationResponse {
Locale: 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 = buildInvitationLink(invitation.InvitationID, invitation.InvitationToken, teamConfig, requestBaseURL)
}
return invitation
}
// mapToInvitationDetailResponse converts a map to InvitationDetailResponse
func mapToInvitationDetailResponse(data maps.MapStr) InvitationDetailResponse {
func mapToInvitationDetailResponse(data maps.MapStr, requestBaseURL string) InvitationDetailResponse {
invitation := InvitationDetailResponse{
InvitationResponse: mapToInvitationResponse(data),
InvitationResponse: mapToInvitationResponse(data, requestBaseURL),
}
// Add user info if available (could be joined from user table)

View file

@ -29,6 +29,10 @@ func GinTeamConfig(c *gin.Context) {
locale = "en" // default locale
}
// Clean locale: remove whitespace and special characters
locale = strings.TrimSpace(locale)
locale = strings.Trim(locale, "?&=")
config := GetTeamConfig(locale)
if config == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Team configuration not found"})

View file

@ -315,6 +315,7 @@ type InvitationResponse struct {
InvitedBy string `json:"invited_by"`
InvitedAt string `json:"invited_at"`
InvitationToken string `json:"invitation_token,omitempty"`
InvitationLink string `json:"invitation_link,omitempty"` // Full invitation link
InvitationExpiresAt string `json:"invitation_expires_at,omitempty"`
Message string `json:"message,omitempty"`
Settings *InvitationSettings `json:"settings,omitempty"`
@ -339,6 +340,7 @@ type CreateInvitationRequest struct {
Message string `json:"message,omitempty"`
Expiry string `json:"expiry,omitempty"` // Custom expiry duration (e.g., "1d", "8h"), defaults to team config
SendEmail *bool `json:"send_email,omitempty"` // Whether to send email (defaults to false)
Locale string `json:"locale,omitempty"` // Language code for email template (e.g., "zh-CN", "en")
Settings *InvitationSettings `json:"settings,omitempty"`
}