diff --git a/docs/parity/gochat_routes.txt b/docs/parity/gochat_routes.txt index 45250c8a..17b7d4d0 100644 --- a/docs/parity/gochat_routes.txt +++ b/docs/parity/gochat_routes.txt @@ -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 diff --git a/internal/handler/api/v1/inbox_handler.go b/internal/handler/api/v1/inbox_handler.go index dc322be0..e86eead3 100644 --- a/internal/handler/api/v1/inbox_handler.go +++ b/internal/handler/api/v1/inbox_handler.go @@ -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)) } diff --git a/internal/handler/api/v1/inbox_handler_parity_test.go b/internal/handler/api/v1/inbox_handler_parity_test.go new file mode 100644 index 00000000..63278ad2 --- /dev/null +++ b/internal/handler/api/v1/inbox_handler_parity_test.go @@ -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 +} diff --git a/internal/handler/api/v1/inbox_serializer.go b/internal/handler/api/v1/inbox_serializer.go new file mode 100644 index 00000000..4bf59b25 --- /dev/null +++ b/internal/handler/api/v1/inbox_serializer.go @@ -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 +} diff --git a/internal/router/router.go b/internal/router/router.go index 5d208ec4..765e997a 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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)