feat(inboxes): align chatwoot inbox serializers
This commit is contained in:
@@ -455,6 +455,7 @@ PATCH /api/v1/accounts/:account_id/custom_attribute_definitions/:id
|
||||
PATCH /api/v1/accounts/:account_id/custom_filters/:id
|
||||
PATCH /api/v1/accounts/:account_id/dashboard_apps/:dashboard_app_id
|
||||
PATCH /api/v1/accounts/:account_id/inbox_members/
|
||||
PATCH /api/v1/accounts/:account_id/inboxes/:inbox_id
|
||||
PATCH /api/v1/accounts/:account_id/inboxes/:inbox_id/email_channels/:em_id
|
||||
PATCH /api/v1/accounts/:account_id/inboxes/:inbox_id/inbox_limits/:id
|
||||
PATCH /api/v1/accounts/:account_id/inboxes/:inbox_id/instagram_channels/:ig_id
|
||||
@@ -818,4 +819,4 @@ PUT /public/api/v1/csat_survey/:id
|
||||
PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id
|
||||
PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages/:message_id
|
||||
PUT /widget/direct_uploads/:upload_uuid
|
||||
TOTAL: 820
|
||||
TOTAL: 821
|
||||
|
||||
@@ -46,16 +46,13 @@ func (h *InboxHandler) List(c *gin.Context) {
|
||||
perPage := getPageSize(c)
|
||||
offset := (page - 1) * perPage
|
||||
|
||||
inboxes, total, svcErr := h.svc.ListByAccount(c.Request.Context(), accountID, offset, perPage)
|
||||
inboxes, _, svcErr := h.svc.ListByAccount(c.Request.Context(), accountID, offset, perPage)
|
||||
if svcErr != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to list inboxes"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"inboxes": inboxes,
|
||||
"meta": gin.H{"count": total, "page": page, "page_size": perPage},
|
||||
})
|
||||
c.JSON(http.StatusOK, inboxListResponse(inboxes))
|
||||
}
|
||||
|
||||
// @Summary Get a single inbox
|
||||
@@ -93,7 +90,7 @@ func (h *InboxHandler) Get(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, inbox)
|
||||
c.JSON(http.StatusOK, serializeInbox(inbox))
|
||||
}
|
||||
|
||||
// @Summary Create a new inbox
|
||||
@@ -130,7 +127,7 @@ func (h *InboxHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, inbox)
|
||||
c.JSON(http.StatusOK, serializeInbox(inbox))
|
||||
}
|
||||
|
||||
// @Summary Update an inbox
|
||||
@@ -176,7 +173,7 @@ func (h *InboxHandler) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, inbox)
|
||||
c.JSON(http.StatusOK, serializeInbox(inbox))
|
||||
}
|
||||
|
||||
// @Summary Delete an inbox
|
||||
@@ -214,7 +211,7 @@ func (h *InboxHandler) Delete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"id": inboxID, "deleted": true})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Your inbox deletion request will be processed in some time."})
|
||||
}
|
||||
|
||||
// ========================================
|
||||
@@ -413,13 +410,13 @@ func (h *InboxHandler) DeleteAvatar(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
inbox, svcErr := h.svc.DeleteAvatar(c.Request.Context(), accountID, inboxID)
|
||||
_, svcErr := h.svc.DeleteAvatar(c.Request.Context(), accountID, inboxID)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, inbox)
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// ListCampaigns retrieves all campaigns for a specific inbox.
|
||||
@@ -474,5 +471,5 @@ func (h *InboxHandler) ResetSecret(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, inbox)
|
||||
c.JSON(http.StatusOK, serializeInbox(inbox))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
"github.com/gochat/gochat/internal/service"
|
||||
)
|
||||
|
||||
func TestInboxHandler_ChatwootSerializerParity(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("file:inbox_handler_parity?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 Parity", Locale: "en", Active: true}
|
||||
require.NoError(t, db.Create(account).Error)
|
||||
widget := &model.Inbox{
|
||||
AccountID: account.ID,
|
||||
Name: "Website",
|
||||
ChannelType: "web_widget",
|
||||
ChannelID: 11,
|
||||
Enabled: true,
|
||||
AvatarURL: "https://example.com/avatar.png",
|
||||
GreetingEnabled: true,
|
||||
GreetingMessage: "Welcome",
|
||||
EnableEmailCollect: true,
|
||||
EnableAutoAssignment: true,
|
||||
AllowMessagesAfterResolved: true,
|
||||
SenderNameType: "friendly_name",
|
||||
BusinessName: "Example Co",
|
||||
Timezone: "UTC",
|
||||
ChannelConfig: `{"website_token":"web-token","hmac_token":"hmac-token","widget_color":"#1f93ff","website_url":"https://example.com","welcome_title":"Hi","welcome_tagline":"We reply fast","reply_time":"in_a_few_minutes","pre_chat_form_enabled":true,"pre_chat_form_options":{"fields":[{"name":"email"}]},"continuity_via_email":true}`,
|
||||
}
|
||||
require.NoError(t, db.Create(widget).Error)
|
||||
apiInbox := &model.Inbox{
|
||||
AccountID: account.ID,
|
||||
Name: "API",
|
||||
ChannelType: "api",
|
||||
ChannelID: 12,
|
||||
Enabled: true,
|
||||
WebhookURL: "https://example.com/hook",
|
||||
Secret: "api-secret",
|
||||
ChannelConfig: `{"identifier":"api-identifier","hmac_token":"api-hmac","additional_attributes":{"source":"frontend"}}`,
|
||||
}
|
||||
require.NoError(t, db.Create(apiInbox).Error)
|
||||
|
||||
router := setupInboxParityRouter(db)
|
||||
|
||||
list := inboxParityRequest(t, router, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/inboxes/", account.ID), nil)
|
||||
require.Equal(t, http.StatusOK, list.Code, list.Body.String())
|
||||
listData := inboxParityObject(t, list)
|
||||
require.NotContains(t, listData, "inboxes")
|
||||
payload := listData["payload"].([]any)
|
||||
require.Len(t, payload, 2)
|
||||
first := payload[0].(map[string]any)
|
||||
require.Equal(t, "Channel::Api", first["channel_type"])
|
||||
require.Equal(t, "api-secret", first["secret"])
|
||||
require.Equal(t, "api-identifier", first["inbox_identifier"])
|
||||
|
||||
show := inboxParityRequest(t, router, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d", account.ID, widget.ID), nil)
|
||||
require.Equal(t, http.StatusOK, show.Code, show.Body.String())
|
||||
showData := inboxParityObject(t, show)
|
||||
require.NotContains(t, showData, "payload")
|
||||
require.Equal(t, "Channel::WebWidget", showData["channel_type"])
|
||||
require.Equal(t, "web-token", showData["website_token"])
|
||||
require.Equal(t, "hmac-token", showData["hmac_token"])
|
||||
require.Equal(t, "#1f93ff", showData["widget_color"])
|
||||
require.Equal(t, "https://example.com", showData["website_url"])
|
||||
require.Equal(t, true, showData["pre_chat_form_enabled"])
|
||||
require.IsType(t, []any{}, showData["working_hours"])
|
||||
|
||||
update := inboxParityRequest(t, router, http.MethodPatch, fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d", account.ID, widget.ID), map[string]any{
|
||||
"name": "Website Updated",
|
||||
"enable_auto_assignment": false,
|
||||
})
|
||||
require.Equal(t, http.StatusOK, update.Code, update.Body.String())
|
||||
updateData := inboxParityObject(t, update)
|
||||
require.Equal(t, "Website Updated", updateData["name"])
|
||||
require.Equal(t, false, updateData["enable_auto_assignment"])
|
||||
|
||||
deleteAvatar := inboxParityRequest(t, router, http.MethodDelete, fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d/avatar", account.ID, widget.ID), nil)
|
||||
require.Equal(t, http.StatusOK, deleteAvatar.Code, deleteAvatar.Body.String())
|
||||
require.Empty(t, deleteAvatar.Body.String())
|
||||
|
||||
destroy := inboxParityRequest(t, router, http.MethodDelete, fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d", account.ID, apiInbox.ID), nil)
|
||||
require.Equal(t, http.StatusOK, destroy.Code, destroy.Body.String())
|
||||
require.Equal(t, "Your inbox deletion request will be processed in some time.", inboxParityObject(t, destroy)["message"])
|
||||
}
|
||||
|
||||
func setupInboxParityRouter(db *gorm.DB) *gin.Engine {
|
||||
inboxSvc := service.NewInboxService(repository.NewInboxRepo(db), nil, nil, nil, nil, nil, nil)
|
||||
handler := NewInboxHandler(inboxSvc)
|
||||
router := gin.New()
|
||||
inboxes := router.Group("/api/v1/accounts/:id/inboxes")
|
||||
{
|
||||
inboxes.GET("/", handler.List)
|
||||
inboxes.GET("/:inbox_id", handler.Get)
|
||||
inboxes.POST("/", handler.Create)
|
||||
inboxes.PUT("/:inbox_id", handler.Update)
|
||||
inboxes.PATCH("/:inbox_id", handler.Update)
|
||||
inboxes.DELETE("/:inbox_id", handler.Delete)
|
||||
inboxes.DELETE("/:inbox_id/avatar", handler.DeleteAvatar)
|
||||
}
|
||||
return router
|
||||
}
|
||||
|
||||
func inboxParityRequest(t *testing.T, router *gin.Engine, method string, path string, body any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var reader *bytes.Reader
|
||||
if body == nil {
|
||||
reader = bytes.NewReader(nil)
|
||||
} else {
|
||||
payload, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
reader = bytes.NewReader(payload)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, reader)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
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
|
||||
require.NoError(t, json.Unmarshal(response.Body.Bytes(), &data), response.Body.String())
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
)
|
||||
|
||||
func inboxListResponse(inboxes []model.Inbox) map[string]any {
|
||||
payload := make([]any, 0, len(inboxes))
|
||||
for i := range inboxes {
|
||||
payload = append(payload, serializeInbox(&inboxes[i]))
|
||||
}
|
||||
return map[string]any{"payload": payload}
|
||||
}
|
||||
|
||||
func serializeInbox(inbox *model.Inbox) map[string]any {
|
||||
config := parseInboxChannelConfig(inbox.ChannelConfig)
|
||||
payload := map[string]any{
|
||||
"id": inbox.ID,
|
||||
"avatar_url": inbox.AvatarURL,
|
||||
"channel_id": inbox.ChannelID,
|
||||
"name": inbox.Name,
|
||||
"channel_type": chatwootChannelType(inbox.ChannelType),
|
||||
"greeting_enabled": inbox.GreetingEnabled,
|
||||
"greeting_message": inbox.GreetingMessage,
|
||||
"working_hours_enabled": inbox.WorkingHoursEnabled,
|
||||
"enable_email_collect": inbox.EnableEmailCollect,
|
||||
"csat_survey_enabled": inbox.CsatSurveyEnabled,
|
||||
"csat_config": jsonStringObject(inbox.CsatConfig),
|
||||
"enable_auto_assignment": inbox.EnableAutoAssignment,
|
||||
"auto_assignment_config": configValue(config, "auto_assignment_config"),
|
||||
"out_of_office_message": inbox.OutOfOfficeMessage,
|
||||
"working_hours": configArray(config, "working_hours"),
|
||||
"timezone": inbox.Timezone,
|
||||
"callback_webhook_url": configValue(config, "callback_webhook_url"),
|
||||
"allow_messages_after_resolved": inbox.AllowMessagesAfterResolved,
|
||||
"lock_to_single_conversation": inbox.LockToSingleConversation,
|
||||
"sender_name_type": inbox.SenderNameType,
|
||||
"business_name": inbox.BusinessName,
|
||||
"allowed_domains": configValue(config, "allowed_domains"),
|
||||
"widget_color": configValue(config, "widget_color"),
|
||||
"website_url": configValue(config, "website_url"),
|
||||
"hmac_mandatory": configValue(config, "hmac_mandatory"),
|
||||
"welcome_title": configValue(config, "welcome_title"),
|
||||
"welcome_tagline": configValue(config, "welcome_tagline"),
|
||||
"web_widget_script": configValue(config, "web_widget_script"),
|
||||
"website_token": configValue(config, "website_token"),
|
||||
"selected_feature_flags": configValue(config, "selected_feature_flags"),
|
||||
"reply_time": configValue(config, "reply_time"),
|
||||
"provider": configValue(config, "provider"),
|
||||
}
|
||||
|
||||
switch chatwootChannelType(inbox.ChannelType) {
|
||||
case "Channel::WebWidget":
|
||||
payload["hmac_token"] = configValue(config, "hmac_token")
|
||||
payload["pre_chat_form_enabled"] = configValue(config, "pre_chat_form_enabled")
|
||||
payload["pre_chat_form_options"] = configValue(config, "pre_chat_form_options")
|
||||
payload["continuity_via_email"] = configValue(config, "continuity_via_email")
|
||||
case "Channel::Api":
|
||||
payload["hmac_token"] = configValue(config, "hmac_token")
|
||||
payload["secret"] = inbox.Secret
|
||||
payload["webhook_url"] = inbox.WebhookURL
|
||||
payload["inbox_identifier"] = firstConfigValue(config, "inbox_identifier", "identifier")
|
||||
payload["additional_attributes"] = configValue(config, "additional_attributes")
|
||||
case "Channel::Telegram":
|
||||
payload["bot_name"] = configValue(config, "bot_name")
|
||||
case "Channel::FacebookPage":
|
||||
payload["page_id"] = configValue(config, "page_id")
|
||||
payload["reauthorization_required"] = configValue(config, "reauthorization_required")
|
||||
case "Channel::Instagram":
|
||||
payload["instagram_id"] = configValue(config, "instagram_id")
|
||||
payload["reauthorization_required"] = configValue(config, "reauthorization_required")
|
||||
case "Channel::Tiktok":
|
||||
payload["reauthorization_required"] = configValue(config, "reauthorization_required")
|
||||
case "Channel::TwilioSms":
|
||||
payload["messaging_service_sid"] = configValue(config, "messaging_service_sid")
|
||||
payload["phone_number"] = configValue(config, "phone_number")
|
||||
payload["medium"] = configValue(config, "medium")
|
||||
payload["content_templates"] = configValue(config, "content_templates")
|
||||
payload["auth_token"] = configValue(config, "auth_token")
|
||||
payload["account_sid"] = configValue(config, "account_sid")
|
||||
payload["api_key_sid"] = configValue(config, "api_key_sid")
|
||||
case "Channel::Email":
|
||||
payload["email"] = configValue(config, "email")
|
||||
payload["forwarding_enabled"] = false
|
||||
payload["imap_enabled"] = configValue(config, "imap_enabled")
|
||||
payload["smtp_enabled"] = configValue(config, "smtp_enabled")
|
||||
case "Channel::Whatsapp":
|
||||
payload["message_templates"] = configValue(config, "message_templates")
|
||||
payload["provider_config"] = configValue(config, "provider_config")
|
||||
payload["reauthorization_required"] = configValue(config, "reauthorization_required")
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
func chatwootChannelType(channelType string) string {
|
||||
if strings.HasPrefix(channelType, "Channel::") {
|
||||
return channelType
|
||||
}
|
||||
aliases := map[string]string{
|
||||
"web_widget": "Channel::WebWidget",
|
||||
"facebook": "Channel::FacebookPage",
|
||||
"instagram": "Channel::Instagram",
|
||||
"twitter": "Channel::TwitterProfile",
|
||||
"twilio_sms": "Channel::TwilioSms",
|
||||
"whatsapp": "Channel::Whatsapp",
|
||||
"api": "Channel::Api",
|
||||
"email": "Channel::Email",
|
||||
"telegram": "Channel::Telegram",
|
||||
"line": "Channel::Line",
|
||||
"sms": "Channel::Sms",
|
||||
"tiktok": "Channel::Tiktok",
|
||||
}
|
||||
if mapped, ok := aliases[channelType]; ok {
|
||||
return mapped
|
||||
}
|
||||
return channelType
|
||||
}
|
||||
|
||||
func parseInboxChannelConfig(raw string) map[string]any {
|
||||
if raw == "" {
|
||||
return map[string]any{}
|
||||
}
|
||||
var config map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &config); err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
func configValue(config map[string]any, key string) any {
|
||||
if value, ok := config[key]; ok {
|
||||
return value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstConfigValue(config map[string]any, keys ...string) any {
|
||||
for _, key := range keys {
|
||||
if value, ok := config[key]; ok {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func configArray(config map[string]any, key string) any {
|
||||
if value, ok := config[key]; ok {
|
||||
return value
|
||||
}
|
||||
return []any{}
|
||||
}
|
||||
|
||||
func jsonStringObject(raw string) any {
|
||||
if raw == "" {
|
||||
return map[string]any{}
|
||||
}
|
||||
var value any
|
||||
if err := json.Unmarshal([]byte(raw), &value); err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -614,6 +614,7 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) {
|
||||
inboxes.POST("/", h.Inbox.Create)
|
||||
inboxes.GET("/:inbox_id", h.Inbox.Get)
|
||||
inboxes.PUT("/:inbox_id", h.Inbox.Update)
|
||||
inboxes.PATCH("/:inbox_id", h.Inbox.Update)
|
||||
inboxes.DELETE("/:inbox_id", h.Inbox.Delete)
|
||||
|
||||
// Inbox member-action routes (ref: Chatwoot InboxesController member actions)
|
||||
|
||||
Reference in New Issue
Block a user