diff --git a/internal/handler/api/v1/inbox_handler.go b/internal/handler/api/v1/inbox_handler.go index e86eead3..0a98e0c0 100644 --- a/internal/handler/api/v1/inbox_handler.go +++ b/internal/handler/api/v1/inbox_handler.go @@ -1,7 +1,10 @@ package v1 import ( + "encoding/json" "net/http" + "strconv" + "strings" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/service" @@ -116,7 +119,7 @@ func (h *InboxHandler) Create(c *gin.Context) { } var req service.CreateInboxRequest - if bindErr := c.ShouldBindJSON(&req); bindErr != nil { + if bindErr := bindCreateInboxRequest(c, &req); bindErr != nil { c.JSON(http.StatusBadRequest, gin.H{"error": bindErr.Error()}) return } @@ -162,7 +165,7 @@ func (h *InboxHandler) Update(c *gin.Context) { } var req service.UpdateInboxRequest - if bindErr := c.ShouldBindJSON(&req); bindErr != nil { + if bindErr := bindUpdateInboxRequest(c, &req); bindErr != nil { c.JSON(http.StatusBadRequest, gin.H{"error": bindErr.Error()}) return } @@ -214,6 +217,232 @@ func (h *InboxHandler) Delete(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "Your inbox deletion request will be processed in some time."}) } +func bindCreateInboxRequest(c *gin.Context, req *service.CreateInboxRequest) error { + if strings.Contains(c.ContentType(), "json") { + return c.ShouldBindJSON(req) + } + values, err := inboxFormValues(c) + if err != nil { + return err + } + applyCreateInboxForm(req, values) + return nil +} + +func bindUpdateInboxRequest(c *gin.Context, req *service.UpdateInboxRequest) error { + if strings.Contains(c.ContentType(), "json") { + return c.ShouldBindJSON(req) + } + values, err := inboxFormValues(c) + if err != nil { + return err + } + applyUpdateInboxForm(req, values) + return nil +} + +func inboxFormValues(c *gin.Context) (map[string][]string, error) { + values := map[string][]string{} + if strings.Contains(c.ContentType(), "multipart/form-data") { + if err := c.Request.ParseMultipartForm(32 << 20); err != nil { + return nil, err + } + if c.Request.MultipartForm != nil { + for key, list := range c.Request.MultipartForm.Value { + values[key] = append(values[key], list...) + } + } + return values, nil + } else if err := c.Request.ParseForm(); err != nil { + return nil, err + } + for key, list := range c.Request.PostForm { + values[key] = append(values[key], list...) + } + for key, list := range c.Request.Form { + if _, ok := values[key]; !ok { + values[key] = append(values[key], list...) + } + } + return values, nil +} + +func applyCreateInboxForm(req *service.CreateInboxRequest, values map[string][]string) { + req.Channel = map[string]any{} + for key, list := range values { + value := inboxFormValue(list) + if strings.HasPrefix(key, "channel[") { + applyInboxNestedValue(req.Channel, key, list) + continue + } + if strings.HasPrefix(key, "csat_config[") { + if req.CsatConfig == nil { + req.CsatConfig = map[string]any{} + } + applyInboxNestedValue(req.CsatConfig, key, list) + continue + } + switch key { + case "name": + req.Name = value + case "channel_type": + req.ChannelType = value + case "enabled": + req.Enabled = inboxBool(value) + case "enable_auto_assignment": + req.EnableAutoAssignment = inboxBool(value) + case "greeting_enabled": + req.GreetingEnabled = inboxBoolPtr(value) + case "greeting_message": + req.GreetingMessage = inboxStringPtr(value) + case "enable_email_collect": + req.EnableEmailCollect = inboxBoolPtr(value) + case "csat_survey_enabled": + req.CsatSurveyEnabled = inboxBoolPtr(value) + case "working_hours_enabled": + req.WorkingHoursEnabled = inboxBoolPtr(value) + case "out_of_office_message": + req.OutOfOfficeMessage = inboxStringPtr(value) + case "timezone": + req.Timezone = inboxStringPtr(value) + case "allow_messages_after_resolved": + req.AllowMessagesAfterResolved = inboxBoolPtr(value) + case "lock_to_single_conversation": + req.LockToSingleConversation = inboxBoolPtr(value) + case "portal_id": + req.PortalID = inboxUintPtr(value) + case "sender_name_type": + req.SenderNameType = inboxStringPtr(value) + case "business_name": + req.BusinessName = inboxStringPtr(value) + case "csat_config": + req.CsatConfig = inboxJSONObject(value) + } + } +} + +func applyUpdateInboxForm(req *service.UpdateInboxRequest, values map[string][]string) { + req.Channel = map[string]any{} + for key, list := range values { + value := inboxFormValue(list) + if strings.HasPrefix(key, "channel[") { + applyInboxNestedValue(req.Channel, key, list) + continue + } + if strings.HasPrefix(key, "csat_config[") { + if req.CsatConfig == nil { + req.CsatConfig = map[string]any{} + } + applyInboxNestedValue(req.CsatConfig, key, list) + continue + } + switch key { + case "name": + req.Name = value + case "enabled": + req.Enabled = inboxBoolPtr(value) + case "enable_auto_assignment": + req.EnableAutoAssignment = inboxBoolPtr(value) + case "greeting_enabled": + req.GreetingEnabled = inboxBoolPtr(value) + case "greeting_message": + req.GreetingMessage = inboxStringPtr(value) + case "enable_email_collect": + req.EnableEmailCollect = inboxBoolPtr(value) + case "csat_survey_enabled": + req.CsatSurveyEnabled = inboxBoolPtr(value) + case "working_hours_enabled": + req.WorkingHoursEnabled = inboxBoolPtr(value) + case "out_of_office_message": + req.OutOfOfficeMessage = inboxStringPtr(value) + case "timezone": + req.Timezone = inboxStringPtr(value) + case "allow_messages_after_resolved": + req.AllowMessagesAfterResolved = inboxBoolPtr(value) + case "lock_to_single_conversation": + req.LockToSingleConversation = inboxBoolPtr(value) + case "portal_id": + req.PortalID = inboxUintPtr(value) + case "sender_name_type": + req.SenderNameType = inboxStringPtr(value) + case "business_name": + req.BusinessName = inboxStringPtr(value) + case "csat_config": + req.CsatConfig = inboxJSONObject(value) + } + } +} + +func applyInboxNestedValue(target map[string]any, key string, values []string) { + inner := strings.TrimSuffix(strings.TrimPrefix(key[strings.Index(key, "[")+1:], ""), "]") + parts := strings.Split(inner, "][") + if len(parts) == 0 || parts[0] == "" { + return + } + if len(parts) == 1 || (len(parts) == 2 && parts[1] == "") { + if len(parts) == 2 && parts[1] == "" { + target[parts[0]] = append([]string(nil), values...) + return + } + target[parts[0]] = inboxFormValue(values) + return + } + nested, _ := target[parts[0]].(map[string]any) + if nested == nil { + nested = map[string]any{} + target[parts[0]] = nested + } + if len(parts) == 3 && parts[2] == "" { + nested[parts[1]] = append([]string(nil), values...) + return + } + nested[parts[1]] = inboxFormValue(values) +} + +func inboxFormValue(values []string) string { + if len(values) == 0 || values[0] == "null" { + return "" + } + return values[0] +} + +func inboxBool(value string) bool { + parsed, _ := strconv.ParseBool(value) + return parsed +} + +func inboxBoolPtr(value string) *bool { + parsed := inboxBool(value) + return &parsed +} + +func inboxStringPtr(value string) *string { + return &value +} + +func inboxUintPtr(value string) *uint { + if value == "" { + return nil + } + parsed, err := strconv.ParseUint(value, 10, 64) + if err != nil { + return nil + } + uintValue := uint(parsed) + return &uintValue +} + +func inboxJSONObject(value string) map[string]any { + if value == "" { + return nil + } + var data map[string]any + if err := json.Unmarshal([]byte(value), &data); err != nil { + return nil + } + return data +} + // ======================================== // Member-action handlers (Chatwoot InboxesController member routes) // ======================================== diff --git a/internal/handler/api/v1/inbox_handler_parity_test.go b/internal/handler/api/v1/inbox_handler_parity_test.go index 63278ad2..1a1b1c3f 100644 --- a/internal/handler/api/v1/inbox_handler_parity_test.go +++ b/internal/handler/api/v1/inbox_handler_parity_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "mime/multipart" "net/http" "net/http/httptest" "testing" @@ -109,6 +110,104 @@ func TestInboxHandler_ChatwootSerializerParity(t *testing.T) { require.Equal(t, "Your inbox deletion request will be processed in some time.", inboxParityObject(t, destroy)["message"]) } +func TestInboxHandler_ChatwootCreateUpdateRequestBinding(t *testing.T) { + gin.SetMode(gin.TestMode) + + db, err := gorm.Open(sqlite.Open("file:inbox_handler_binding?mode=memory&cache=shared"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + require.NoError(t, err) + t.Cleanup(func() { + sqlDB, dbErr := db.DB() + if dbErr == nil { + _ = sqlDB.Close() + } + }) + require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{})) + + account := &model.Account{Name: "Inbox Binding", Locale: "en", Active: true} + require.NoError(t, db.Create(account).Error) + router := setupInboxParityRouter(db) + + websiteCreate := inboxParityMultipartRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/inboxes/", account.ID), map[string][]string{ + "name": {"Website"}, + "greeting_enabled": {"true"}, + "greeting_message": {"Hello from Chatwoot"}, + "enable_email_collect": {"false"}, + "allow_messages_after_resolved": {"true"}, + "sender_name_type": {"business_name"}, + "business_name": {"Acme Support"}, + "channel[type]": {"web_widget"}, + "channel[website_url]": {"https://acme.example"}, + "channel[widget_color]": {"#f97316"}, + "channel[welcome_title]": {"Hi there"}, + "channel[welcome_tagline]": {"We reply quickly"}, + "channel[selected_feature_flags][]": {"attachments", "emoji_picker"}, + }) + require.Equal(t, http.StatusOK, websiteCreate.Code, websiteCreate.Body.String()) + websiteData := inboxParityObject(t, websiteCreate) + require.Equal(t, "Channel::WebWidget", websiteData["channel_type"]) + require.Equal(t, "https://acme.example", websiteData["website_url"]) + require.Equal(t, "#f97316", websiteData["widget_color"]) + require.Equal(t, "Hi there", websiteData["welcome_title"]) + require.NotEmpty(t, websiteData["website_token"]) + require.NotEmpty(t, websiteData["hmac_token"]) + require.Equal(t, false, websiteData["enable_email_collect"]) + require.Equal(t, "business_name", websiteData["sender_name_type"]) + require.Equal(t, "Acme Support", websiteData["business_name"]) + require.ElementsMatch(t, []any{"attachments", "emoji_picker"}, websiteData["selected_feature_flags"].([]any)) + + apiCreate := inboxParityRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/inboxes/", account.ID), map[string]any{ + "name": "API", + "channel": map[string]any{ + "type": "api", + "webhook_url": "https://hooks.example/chatwoot", + }, + }) + require.Equal(t, http.StatusOK, apiCreate.Code, apiCreate.Body.String()) + apiData := inboxParityObject(t, apiCreate) + require.Equal(t, "Channel::Api", apiData["channel_type"]) + require.Equal(t, "https://hooks.example/chatwoot", apiData["webhook_url"]) + require.NotEmpty(t, apiData["secret"]) + require.NotEmpty(t, apiData["hmac_token"]) + require.NotEmpty(t, apiData["inbox_identifier"]) + + telegramCreate := inboxParityRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/inboxes/", account.ID), map[string]any{ + "channel": map[string]any{ + "type": "telegram", + "bot_token": "123:telegram-token", + }, + }) + require.Equal(t, http.StatusOK, telegramCreate.Code, telegramCreate.Body.String()) + telegramData := inboxParityObject(t, telegramCreate) + require.Equal(t, "Telegram", telegramData["name"]) + require.Equal(t, "Channel::Telegram", telegramData["channel_type"]) + + websiteID := uint(websiteData["id"].(float64)) + update := inboxParityMultipartRequest(t, router, http.MethodPatch, fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d", account.ID, websiteID), map[string][]string{ + "name": {"Website Updated"}, + "enable_auto_assignment": {"true"}, + "csat_survey_enabled": {"true"}, + "csat_config[display_type]": {"emoji"}, + "csat_config[message]": {"Rate this conversation"}, + "csat_config[survey_rules][operator]": {"contains"}, + "csat_config[survey_rules][values][]": {"vip", "priority"}, + "channel[website_url]": {"https://updated.example"}, + "channel[welcome_tagline]": {"Updated tagline"}, + }) + require.Equal(t, http.StatusOK, update.Code, update.Body.String()) + updateData := inboxParityObject(t, update) + require.Equal(t, "Website Updated", updateData["name"]) + require.Equal(t, true, updateData["enable_auto_assignment"]) + require.Equal(t, true, updateData["csat_survey_enabled"]) + require.Equal(t, "https://updated.example", updateData["website_url"]) + require.Equal(t, "Updated tagline", updateData["welcome_tagline"]) + csatConfig := updateData["csat_config"].(map[string]any) + require.Equal(t, "Rate this conversation", csatConfig["message"]) + require.Equal(t, "Please rate us", csatConfig["button_text"]) + require.ElementsMatch(t, []any{"vip", "priority"}, csatConfig["survey_rules"].(map[string]any)["values"].([]any)) +} + func setupInboxParityRouter(db *gorm.DB) *gin.Engine { inboxSvc := service.NewInboxService(repository.NewInboxRepo(db), nil, nil, nil, nil, nil, nil) handler := NewInboxHandler(inboxSvc) @@ -143,6 +242,23 @@ func inboxParityRequest(t *testing.T, router *gin.Engine, method string, path st return w } +func inboxParityMultipartRequest(t *testing.T, router *gin.Engine, method string, path string, fields map[string][]string) *httptest.ResponseRecorder { + t.Helper() + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + for key, values := range fields { + for _, value := range values { + require.NoError(t, writer.WriteField(key, value)) + } + } + require.NoError(t, writer.Close()) + req := httptest.NewRequest(method, path, body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w +} + func inboxParityObject(t *testing.T, response *httptest.ResponseRecorder) map[string]any { t.Helper() var data map[string]any diff --git a/internal/service/inbox_service.go b/internal/service/inbox_service.go index 3ac8e430..c0ca42d5 100644 --- a/internal/service/inbox_service.go +++ b/internal/service/inbox_service.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "github.com/gochat/gochat/internal/campaign" whatsapp "github.com/gochat/gochat/internal/channel/whatsapp" @@ -72,10 +73,24 @@ func (s *InboxService) GetByAccountAndID(ctx context.Context, accountID, id uint // CreateInboxRequest is the DTO for creating an inbox. type CreateInboxRequest struct { - Name string `json:"name" validate:"required,min=2"` - ChannelType string `json:"channel_type" validate:"required,oneof=web_widget telegram facebook instagram whatsapp email api tiktok line twilio_sms"` - Enabled bool `json:"enabled"` - EnableAutoAssignment bool `json:"enable_auto_assignment"` + Name string `json:"name" validate:"omitempty,min=2"` + ChannelType string `json:"channel_type"` + Channel map[string]any `json:"channel,omitempty"` + Enabled bool `json:"enabled"` + EnableAutoAssignment bool `json:"enable_auto_assignment"` + GreetingEnabled *bool `json:"greeting_enabled,omitempty"` + GreetingMessage *string `json:"greeting_message,omitempty"` + EnableEmailCollect *bool `json:"enable_email_collect,omitempty"` + CsatSurveyEnabled *bool `json:"csat_survey_enabled,omitempty"` + WorkingHoursEnabled *bool `json:"working_hours_enabled,omitempty"` + OutOfOfficeMessage *string `json:"out_of_office_message,omitempty"` + Timezone *string `json:"timezone,omitempty"` + AllowMessagesAfterResolved *bool `json:"allow_messages_after_resolved,omitempty"` + LockToSingleConversation *bool `json:"lock_to_single_conversation,omitempty"` + PortalID *uint `json:"portal_id,omitempty"` + SenderNameType *string `json:"sender_name_type,omitempty"` + BusinessName *string `json:"business_name,omitempty"` + CsatConfig map[string]any `json:"csat_config,omitempty"` } // Create creates a new inbox. @@ -83,22 +98,48 @@ func (s *InboxService) Create(ctx context.Context, accountID uint, req CreateInb if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, err } + req.ChannelType = normalizeInboxChannelType(firstNonEmpty(req.ChannelType, mapString(req.Channel, "type"))) validChannelTypes := map[string]bool{ "web_widget": true, "telegram": true, "facebook": true, "instagram": true, "whatsapp": true, "email": true, "api": true, - "tiktok": true, "line": true, "twilio_sms": true, + "tiktok": true, "line": true, "twilio_sms": true, "sms": true, } if !validChannelTypes[req.ChannelType] { return nil, errors.New("invalid channel_type") } + if req.Name == "" { + req.Name = defaultInboxName(req.ChannelType, req.Channel) + } + if len(req.Name) < 2 { + return nil, errors.New("name is too short") + } + + channelConfig := buildInitialInboxChannelConfig(req.ChannelType, req.Channel) + channelConfigJSON, err := json.Marshal(channelConfig) + if err != nil { + return nil, err + } inbox := &model.Inbox{ - AccountID: accountID, - Name: req.Name, - ChannelType: req.ChannelType, - Enabled: req.Enabled, - EnableAutoAssignment: req.EnableAutoAssignment, + AccountID: accountID, + Name: req.Name, + ChannelType: req.ChannelType, + Enabled: true, + EnableAutoAssignment: req.EnableAutoAssignment, + EnableEmailCollect: true, + AllowMessagesAfterResolved: true, + SenderNameType: "friendly_name", + Timezone: "UTC", + ChannelConfig: string(channelConfigJSON), + } + if req.Enabled { + inbox.Enabled = true + } + applyCreateInboxSettings(inbox, req) + if req.ChannelType == "api" { + inbox.WebhookURL = mapString(req.Channel, "webhook_url") + inbox.Secret = firstNonEmpty(mapString(req.Channel, "secret"), generateInboxSecret()) } // ChannelID is set after channel-specific config is created @@ -109,14 +150,33 @@ func (s *InboxService) Create(ctx context.Context, accountID uint, req CreateInb applogger.L().Errorf("Failed to create inbox: %v", err) return nil, err } + applyCreateInboxSettings(inbox, req) + if err := s.repo.Update(ctx, inbox); err != nil { + applogger.L().Errorf("Failed to persist inbox defaults: %v", err) + return nil, err + } return inbox, nil } // UpdateInboxRequest is the DTO for updating an inbox. type UpdateInboxRequest struct { - Name string `json:"name,omitempty" validate:"omitempty,min=2"` - Enabled *bool `json:"enabled,omitempty"` - EnableAutoAssignment *bool `json:"enable_auto_assignment,omitempty"` + Name string `json:"name,omitempty" validate:"omitempty,min=2"` + Enabled *bool `json:"enabled,omitempty"` + EnableAutoAssignment *bool `json:"enable_auto_assignment,omitempty"` + Channel map[string]any `json:"channel,omitempty"` + GreetingEnabled *bool `json:"greeting_enabled,omitempty"` + GreetingMessage *string `json:"greeting_message,omitempty"` + EnableEmailCollect *bool `json:"enable_email_collect,omitempty"` + CsatSurveyEnabled *bool `json:"csat_survey_enabled,omitempty"` + WorkingHoursEnabled *bool `json:"working_hours_enabled,omitempty"` + OutOfOfficeMessage *string `json:"out_of_office_message,omitempty"` + Timezone *string `json:"timezone,omitempty"` + AllowMessagesAfterResolved *bool `json:"allow_messages_after_resolved,omitempty"` + LockToSingleConversation *bool `json:"lock_to_single_conversation,omitempty"` + PortalID *uint `json:"portal_id,omitempty"` + SenderNameType *string `json:"sender_name_type,omitempty"` + BusinessName *string `json:"business_name,omitempty"` + CsatConfig map[string]any `json:"csat_config,omitempty"` } // Update modifies an existing inbox. @@ -139,6 +199,19 @@ func (s *InboxService) Update(ctx context.Context, accountID, id uint, req Updat if req.EnableAutoAssignment != nil { inbox.EnableAutoAssignment = *req.EnableAutoAssignment } + applyUpdateInboxSettings(inbox, req) + if len(req.Channel) > 0 { + config := parseChannelConfigMap(inbox.ChannelConfig) + mergeInboxChannelConfig(config, req.Channel) + configJSON, err := json.Marshal(config) + if err != nil { + return nil, err + } + inbox.ChannelConfig = string(configJSON) + if webhookURL := mapString(req.Channel, "webhook_url"); webhookURL != "" { + inbox.WebhookURL = webhookURL + } + } if err := s.repo.Update(ctx, inbox); err != nil { return nil, err @@ -146,6 +219,265 @@ func (s *InboxService) Update(ctx context.Context, accountID, id uint, req Updat return inbox, nil } +func applyCreateInboxSettings(inbox *model.Inbox, req CreateInboxRequest) { + if req.GreetingEnabled != nil { + inbox.GreetingEnabled = *req.GreetingEnabled + } + if req.GreetingMessage != nil { + inbox.GreetingMessage = *req.GreetingMessage + } + if req.EnableEmailCollect != nil { + inbox.EnableEmailCollect = *req.EnableEmailCollect + } + if req.CsatSurveyEnabled != nil { + inbox.CsatSurveyEnabled = *req.CsatSurveyEnabled + } + if req.WorkingHoursEnabled != nil { + inbox.WorkingHoursEnabled = *req.WorkingHoursEnabled + } + if req.OutOfOfficeMessage != nil { + inbox.OutOfOfficeMessage = *req.OutOfOfficeMessage + } + if req.Timezone != nil && *req.Timezone != "" { + inbox.Timezone = *req.Timezone + } + if req.AllowMessagesAfterResolved != nil { + inbox.AllowMessagesAfterResolved = *req.AllowMessagesAfterResolved + } + if req.LockToSingleConversation != nil { + inbox.LockToSingleConversation = *req.LockToSingleConversation + } + if req.PortalID != nil { + inbox.PortalID = req.PortalID + } + if req.SenderNameType != nil && *req.SenderNameType != "" { + inbox.SenderNameType = *req.SenderNameType + } + if req.BusinessName != nil { + inbox.BusinessName = *req.BusinessName + } + if req.CsatConfig != nil { + inbox.CsatConfig = marshalInboxJSON(formatInboxCsatConfig(req.CsatConfig)) + } +} + +func applyUpdateInboxSettings(inbox *model.Inbox, req UpdateInboxRequest) { + if req.GreetingEnabled != nil { + inbox.GreetingEnabled = *req.GreetingEnabled + } + if req.GreetingMessage != nil { + inbox.GreetingMessage = *req.GreetingMessage + } + if req.EnableEmailCollect != nil { + inbox.EnableEmailCollect = *req.EnableEmailCollect + } + if req.CsatSurveyEnabled != nil { + inbox.CsatSurveyEnabled = *req.CsatSurveyEnabled + } + if req.WorkingHoursEnabled != nil { + inbox.WorkingHoursEnabled = *req.WorkingHoursEnabled + } + if req.OutOfOfficeMessage != nil { + inbox.OutOfOfficeMessage = *req.OutOfOfficeMessage + } + if req.Timezone != nil { + inbox.Timezone = *req.Timezone + } + if req.AllowMessagesAfterResolved != nil { + inbox.AllowMessagesAfterResolved = *req.AllowMessagesAfterResolved + } + if req.LockToSingleConversation != nil { + inbox.LockToSingleConversation = *req.LockToSingleConversation + } + if req.PortalID != nil { + inbox.PortalID = req.PortalID + } + if req.SenderNameType != nil { + inbox.SenderNameType = *req.SenderNameType + } + if req.BusinessName != nil { + inbox.BusinessName = *req.BusinessName + } + if req.CsatConfig != nil { + inbox.CsatConfig = marshalInboxJSON(formatInboxCsatConfig(req.CsatConfig)) + } +} + +func buildInitialInboxChannelConfig(channelType string, channel map[string]any) map[string]interface{} { + config := map[string]interface{}{} + mergeInboxChannelConfig(config, channel) + switch channelType { + case "web_widget": + if _, ok := config["website_token"]; !ok { + if token, err := widgetGenerateToken(12); err == nil { + config["website_token"] = token + config["identifier"] = token + } + } + if _, ok := config["hmac_token"]; !ok { + if token, err := widgetGenerateToken(24); err == nil { + config["hmac_token"] = token + } + } + if _, ok := config["widget_color"]; !ok { + config["widget_color"] = "#1f93ff" + } + case "api": + if _, ok := config["identifier"]; !ok { + if token, err := widgetGenerateToken(12); err == nil { + config["identifier"] = token + config["inbox_identifier"] = token + } + } + if _, ok := config["hmac_token"]; !ok { + config["hmac_token"] = generateInboxSecret() + } + } + delete(config, "type") + return config +} + +func mergeInboxChannelConfig(config map[string]interface{}, channel map[string]any) { + for key, value := range channel { + normalizedKey := normalizeInboxConfigKey(key) + if normalizedKey == "type" || value == nil { + continue + } + if stringValue, ok := value.(string); ok && stringValue == "null" { + config[normalizedKey] = nil + continue + } + config[normalizedKey] = value + } +} + +func normalizeInboxChannelType(channelType string) string { + channelType = strings.TrimSpace(channelType) + aliases := map[string]string{ + "Channel::WebWidget": "web_widget", + "Channel::Api": "api", + "Channel::Email": "email", + "Channel::Line": "line", + "Channel::Telegram": "telegram", + "Channel::Whatsapp": "whatsapp", + "Channel::Sms": "sms", + "Channel::TwilioSms": "twilio_sms", + "Channel::FacebookPage": "facebook", + "Channel::Instagram": "instagram", + "Channel::Tiktok": "tiktok", + "Channel::TikTok": "tiktok", + } + if mapped, ok := aliases[channelType]; ok { + return mapped + } + return channelType +} + +func defaultInboxName(channelType string, channel map[string]any) string { + for _, key := range []string{"name", "bot_name", "email", "website_url", "phone_number", "line_channel_id"} { + if value := mapString(channel, key); value != "" { + return value + } + } + names := map[string]string{ + "web_widget": "Website", + "telegram": "Telegram", + "facebook": "Facebook", + "instagram": "Instagram", + "whatsapp": "WhatsApp", + "email": "Email", + "api": "API", + "line": "LINE", + "sms": "SMS", + "twilio_sms": "Twilio SMS", + "tiktok": "TikTok", + } + if name, ok := names[channelType]; ok { + return name + } + return "Inbox" +} + +func normalizeInboxConfigKey(key string) string { + switch key { + case "selectedFeatureFlags": + return "selected_feature_flags" + case "welcomeTagline": + return "welcome_tagline" + case "welcomeTitle": + return "welcome_title" + case "websiteUrl": + return "website_url" + case "widgetColor": + return "widget_color" + case "webhookUrl": + return "webhook_url" + } + return key +} + +func formatInboxCsatConfig(config map[string]any) map[string]any { + formatted := map[string]any{ + "display_type": "emoji", + "message": "", + "button_text": "Please rate us", + "language": "en", + "survey_rules": map[string]any{"operator": "contains", "values": []any{}}, + } + for key, value := range config { + formatted[key] = value + } + if rules, ok := formatted["survey_rules"].(map[string]any); ok { + if _, ok := rules["operator"]; !ok { + rules["operator"] = "contains" + } + if _, ok := rules["values"]; !ok { + rules["values"] = []any{} + } + } else { + formatted["survey_rules"] = map[string]any{"operator": "contains", "values": []any{}} + } + return formatted +} + +func marshalInboxJSON(value any) string { + data, err := json.Marshal(value) + if err != nil { + return "{}" + } + return string(data) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +func mapString(values map[string]any, key string) string { + if values == nil { + return "" + } + value, ok := values[key] + if !ok { + value, ok = values[normalizeInboxConfigKey(key)] + } + if !ok || value == nil { + return "" + } + switch typed := value.(type) { + case string: + return typed + case fmt.Stringer: + return typed.String() + default: + return fmt.Sprint(typed) + } +} + // Delete soft-deletes an inbox. func (s *InboxService) Delete(ctx context.Context, id uint) error { return s.repo.Delete(ctx, id)