feat(inboxes): bind chatwoot channel settings
This commit is contained in:
@@ -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)
|
||||
// ========================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user