diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 4fb0e92a..ed5e3fb9 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -463,3 +463,4 @@ Verification milestone gates: - 2026-06-04: Expanded Phase 2 route parity from 81 to 138 tracked frontend-critical account routes. Added account-scoped notification routes, `notification_settings` PUT alias, and Chatwoot account-level `inbox_members` create/show/update/destroy handlers. Regenerated parity report: 138 exact, 0 method-compatible, 0 parameter-compatible, 0 missing out of 138; route dump now reports `TOTAL: 723`. - 2026-06-04: Expanded Phase 2 route parity from 138 to 251 tracked frontend-critical routes. Added tracking and route-level coverage for Captain/Copilot, assignment policies, `/api/v1/widget`, public inbox/contact/conversation/message APIs, public CSAT survey, and `/api/v2` reports. Regenerated parity report: 251 exact, 0 method-compatible, 0 parameter-compatible, 0 missing out of 251; route dump now reports `TOTAL: 791`. Added router boot regression coverage for Captain static/dynamic routes, widget collection routes, public nested message routes, and v2 reports. - 2026-06-04: Started Phase 3/6 widget behavior parity for the reused Chatwoot frontend. `/api/v1/widget/config` now returns Chatwoot-style `website_channel_config`, contact pubsub token, and global config; `/api/v1/widget/messages` accepts `X-Auth-Token` and nested `message.content`, returns Chatwoot message shape, and exposes latest messages as `{payload, meta}`; `/api/v1/widget/contact` and core conversation actions now route to real handlers instead of parity stubs. Legacy `/widget/*` response compatibility is preserved. Focused widget/service/router tests pass. +- 2026-06-04: Continued Phase 3/6 widget behavior parity. Replaced more `/api/v1/widget` stubs with handlers for `campaigns`, `events`, `inbox_members`, `labels`, and label removal. Inbox member payload now follows Chatwoot `{payload: [...]}` shape; campaigns return enabled inbox campaigns with trigger rules; events validate website/contact token context and return `204`; labels mutate the latest widget conversation only when the label exists in the account. Added focused widget handler coverage for available agents, events, and label add/remove. Remaining widget stubs: message update, transcript, `contact/set_user`, and Dyte participant integration; public inbox/contact/conversation/message routes are still placeholder-backed. diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index fda6007d..1356d8d6 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -670,7 +670,7 @@ func Bootstrap(env string) (*App, error) { widgetThemeConfigRepo := repository.NewWidgetThemeConfigRepo(db) widgetFileUploadRepo := repository.NewWidgetFileUploadRepo(db) widgetOfflineMessageRepo := repository.NewWidgetOfflineMessageRepo(db) - widgetService := service.NewWidgetService(inboxRepo, contactRepo, contactInboxRepo, conversationRepo, messageRepo, widgetTypingAdapter, widgetThemeConfigRepo, preChatFormRepo, widgetFileUploadRepo, widgetOfflineMessageRepo) + widgetService := service.NewWidgetService(inboxRepo, contactRepo, contactInboxRepo, conversationRepo, messageRepo, widgetTypingAdapter, widgetThemeConfigRepo, preChatFormRepo, widgetFileUploadRepo, widgetOfflineMessageRepo, inboxMemberRepo, tagRepo, campaignRepo) widgetHandler := widget.NewHandler(widgetService) // Upload: DirectUpload repo + service + handler (account-level + widget direct uploads) diff --git a/internal/handler/widget/widget_handler.go b/internal/handler/widget/widget_handler.go index 5bb96f6d..938e49e1 100644 --- a/internal/handler/widget/widget_handler.go +++ b/internal/handler/widget/widget_handler.go @@ -528,6 +528,84 @@ func (h *WidgetHandler) DestroyConversationCustomAttributes(c *gin.Context) { c.JSON(http.StatusOK, conversation) } +func (h *WidgetHandler) ListInboxMembers(c *gin.Context) { + websiteToken := c.Query("website_token") + if websiteToken == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "website_token is required"}) + return + } + members, err := h.widgetService.GetInboxMembersByWebsiteToken(c.Request.Context(), websiteToken) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"payload": members}) +} + +func (h *WidgetHandler) ListCampaigns(c *gin.Context) { + websiteToken := c.Query("website_token") + if websiteToken == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "website_token is required"}) + return + } + campaigns, err := h.widgetService.GetCampaignsByWebsiteToken(c.Request.Context(), websiteToken) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, campaigns) +} + +func (h *WidgetHandler) CreateEvent(c *gin.Context) { + websiteToken := c.Query("website_token") + var req struct { + Name string `json:"name"` + EventInfo map[string]any `json:"event_info"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()}) + return + } + if err := h.widgetService.TrackEvent(c.Request.Context(), websiteToken, widgetTokenFromRequest(c), req.Name, req.EventInfo); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.Status(http.StatusNoContent) +} + +func (h *WidgetHandler) AddLabel(c *gin.Context) { + widgetToken := widgetTokenFromRequest(c) + if widgetToken == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) + return + } + var req struct { + Label string `json:"label"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()}) + return + } + if err := h.widgetService.AddLabelToLatestConversation(c.Request.Context(), widgetToken, req.Label); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.Status(http.StatusNoContent) +} + +func (h *WidgetHandler) RemoveLabel(c *gin.Context) { + widgetToken := widgetTokenFromRequest(c) + if widgetToken == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "widget_token required"}) + return + } + if err := h.widgetService.RemoveLabelFromLatestConversation(c.Request.Context(), widgetToken, c.Param("label_id")); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.Status(http.StatusNoContent) +} + // SubmitOfflineMessage handles a visitor submitting a message when agents are offline. // POST /widget/offline_message // Reference: Chatwoot widget SDK — when business_hours are disabled and no agents online, diff --git a/internal/handler/widget/widget_handler_test.go b/internal/handler/widget/widget_handler_test.go index c64c6a95..55aa5f2c 100644 --- a/internal/handler/widget/widget_handler_test.go +++ b/internal/handler/widget/widget_handler_test.go @@ -19,6 +19,7 @@ import ( "gorm.io/gorm" "gorm.io/gorm/logger" + "github.com/gochat/gochat/internal/campaign" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/service" @@ -56,6 +57,11 @@ func setupWidgetHandlerTest(t *testing.T) (*gorm.DB, *gin.Engine, *WidgetHandler &model.PreChatForm{}, &model.WidgetFileUpload{}, &model.WidgetOfflineMessage{}, + &model.User{}, + &model.AccountUser{}, + &model.InboxMember{}, + &model.Tag{}, + &campaign.Campaign{}, )) t.Cleanup(func() { @@ -72,11 +78,14 @@ func setupWidgetHandlerTest(t *testing.T) (*gorm.DB, *gin.Engine, *WidgetHandler preChatFormRepo := repository.NewPreChatFormRepo(db) fileUploadRepo := repository.NewWidgetFileUploadRepo(db) offlineMsgRepo := repository.NewWidgetOfflineMessageRepo(db) + inboxMemberRepo := repository.NewInboxMemberRepo(db) + tagRepo := repository.NewTagRepo(db) + campaignRepo := repository.NewCampaignRepo(db) widgetSvc := service.NewWidgetService( inboxRepo, contactRepo, contactInboxRepo, conversationRepo, messageRepo, &noopTypingIndicatorWidget{}, themeConfigRepo, preChatFormRepo, - fileUploadRepo, offlineMsgRepo, + fileUploadRepo, offlineMsgRepo, inboxMemberRepo, tagRepo, campaignRepo, ) handler := NewHandler(widgetSvc) @@ -101,6 +110,11 @@ func setupWidgetHandlerTest(t *testing.T) (*gorm.DB, *gin.Engine, *WidgetHandler chatwootWidgetGroup.POST("/messages", handler.SendMessage) chatwootWidgetGroup.POST("/conversations", handler.CreateConversation) chatwootWidgetGroup.GET("/contact", handler.GetContact) + chatwootWidgetGroup.GET("/campaigns", handler.ListCampaigns) + chatwootWidgetGroup.GET("/inbox_members", handler.ListInboxMembers) + chatwootWidgetGroup.POST("/events", handler.CreateEvent) + chatwootWidgetGroup.POST("/labels", handler.AddLabel) + chatwootWidgetGroup.DELETE("/labels/:label_id", handler.RemoveLabel) } return db, router, handler @@ -278,6 +292,133 @@ func TestWidgetHandler_ChatwootMessages_AuthTokenAndNestedPayload(t *testing.T) require.Equal(t, http.StatusOK, wContact.Code) } +func TestWidgetHandler_ChatwootInboxMembers_Success(t *testing.T) { + db, router, _ := setupWidgetHandlerTest(t) + _, inbox := seedWidgetHandlerData(t, db) + + user := &model.User{ + AccountID: inbox.AccountID, + Name: "Ada Agent", + Email: "ada@handler.test", + Password: "secret", + AvatarURL: "https://example.test/ada.png", + Active: true, + } + require.NoError(t, db.Create(user).Error) + require.NoError(t, db.Create(&model.InboxMember{ + InboxID: inbox.ID, + UserID: user.ID, + Role: "agent", + AvailabilityStatus: "online", + }).Error) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/widget/inbox_members?website_token=handler_ws_token_123", nil) + router.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + payload := resp["payload"].([]interface{}) + require.Len(t, payload, 1) + member := payload[0].(map[string]interface{}) + assert.Equal(t, "Ada Agent", member["name"]) + assert.Equal(t, "online", member["availability_status"]) +} + +func TestWidgetHandler_ChatwootCampaigns_Success(t *testing.T) { + db, router, _ := setupWidgetHandlerTest(t) + _, inbox := seedWidgetHandlerData(t, db) + + require.NoError(t, db.Create(&campaign.Campaign{ + AccountID: inbox.AccountID, + InboxID: inbox.ID, + DisplayID: 42, + Title: "Welcome", + Message: "Welcome to support", + CampaignType: campaign.CampaignTypeOngoing, + TriggerRules: `{"url":"https://example.test","time_on_page":10}`, + Enabled: true, + }).Error) + disabledCampaign := &campaign.Campaign{ + AccountID: inbox.AccountID, + InboxID: inbox.ID, + DisplayID: 43, + Title: "Disabled", + Message: "Hidden", + CampaignType: campaign.CampaignTypeOngoing, + Enabled: false, + } + require.NoError(t, db.Create(disabledCampaign).Error) + require.NoError(t, db.Model(disabledCampaign).Update("enabled", false).Error) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/widget/campaigns?website_token=handler_ws_token_123", nil) + router.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + var resp []map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.Len(t, resp, 1) + assert.Equal(t, float64(42), resp[0]["id"]) + assert.Equal(t, "Welcome to support", resp[0]["message"]) + rules := resp[0]["trigger_rules"].(map[string]interface{}) + assert.Equal(t, "https://example.test", rules["url"]) +} + +func TestWidgetHandler_ChatwootEventsAndLabels(t *testing.T) { + db, router, _ := setupWidgetHandlerTest(t) + _, inbox := seedWidgetHandlerData(t, db) + require.NoError(t, db.Create(&model.Tag{AccountID: inbox.AccountID, Name: "vip"}).Error) + + wConfig := httptest.NewRecorder() + reqConfig, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123", nil) + router.ServeHTTP(wConfig, reqConfig) + require.Equal(t, http.StatusOK, wConfig.Code) + + var configResp map[string]interface{} + require.NoError(t, json.Unmarshal(wConfig.Body.Bytes(), &configResp)) + authToken := configResp["contact"].(map[string]interface{})["pubsub_token"].(string) + + body := map[string]interface{}{"message": map[string]interface{}{"content": "Label me"}} + bodyJSON, _ := json.Marshal(body) + wMessage := httptest.NewRecorder() + reqMessage, _ := http.NewRequest("POST", "/api/v1/widget/messages", bytes.NewReader(bodyJSON)) + reqMessage.Header.Set("Content-Type", "application/json") + reqMessage.Header.Set("X-Auth-Token", authToken) + router.ServeHTTP(wMessage, reqMessage) + require.Equal(t, http.StatusOK, wMessage.Code) + + wEvent := httptest.NewRecorder() + eventBody, _ := json.Marshal(map[string]interface{}{"name": "widget.opened", "event_info": map[string]interface{}{"source": "test"}}) + reqEvent, _ := http.NewRequest("POST", "/api/v1/widget/events?website_token=handler_ws_token_123", bytes.NewReader(eventBody)) + reqEvent.Header.Set("Content-Type", "application/json") + reqEvent.Header.Set("X-Auth-Token", authToken) + router.ServeHTTP(wEvent, reqEvent) + require.Equal(t, http.StatusNoContent, wEvent.Code) + + wAdd := httptest.NewRecorder() + labelBody, _ := json.Marshal(map[string]interface{}{"label": "vip"}) + reqAdd, _ := http.NewRequest("POST", "/api/v1/widget/labels", bytes.NewReader(labelBody)) + reqAdd.Header.Set("Content-Type", "application/json") + reqAdd.Header.Set("X-Auth-Token", authToken) + router.ServeHTTP(wAdd, reqAdd) + require.Equal(t, http.StatusNoContent, wAdd.Code) + + var conversation model.Conversation + require.NoError(t, db.Where("inbox_id = ?", inbox.ID).First(&conversation).Error) + assert.Equal(t, "vip", conversation.Labels) + + wRemove := httptest.NewRecorder() + reqRemove, _ := http.NewRequest("DELETE", "/api/v1/widget/labels/vip", nil) + reqRemove.Header.Set("X-Auth-Token", authToken) + router.ServeHTTP(wRemove, reqRemove) + require.Equal(t, http.StatusNoContent, wRemove.Code) + + require.NoError(t, db.First(&conversation, conversation.ID).Error) + assert.Empty(t, conversation.Labels) +} + // ========== SendMessage Handler Tests ========== func TestWidgetHandler_SendMessage_Success(t *testing.T) { diff --git a/internal/handler/widget/widget_theme_handler_test.go b/internal/handler/widget/widget_theme_handler_test.go index f6f18de5..61183b68 100644 --- a/internal/handler/widget/widget_theme_handler_test.go +++ b/internal/handler/widget/widget_theme_handler_test.go @@ -87,6 +87,9 @@ func (s *WidgetThemeHandlerTestSuite) SetupSuite() { preChatFormRepo, fileUploadRepo, offlineMessageRepo, + nil, + nil, + nil, ) s.handler = NewHandler(widgetSvc) @@ -364,4 +367,4 @@ func (s *WidgetThemeHandlerTestSuite) TestGetFileUploadStatus_InvalidUploadUUID( func TestWidgetThemeHandlerTestSuite(t *testing.T) { suite.Run(t, new(WidgetThemeHandlerTestSuite)) -} \ No newline at end of file +} diff --git a/internal/router/router.go b/internal/router/router.go index 6249f689..27790d10 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -1703,8 +1703,8 @@ func registerPlatformTokenRoutes(g *gin.RouterGroup, h *Handlers) { func registerChatwootWidgetRoutes(g *gin.RouterGroup, h *Handlers) { g.POST("/direct_uploads", h.Upload.DirectUpload) g.POST("/config", h.Widget.Config) - g.GET("/campaigns", chatwootParityStub) - g.POST("/events", chatwootParityStub) + g.GET("/campaigns", h.Widget.ListCampaigns) + g.POST("/events", h.Widget.CreateEvent) g.GET("/messages", h.Widget.GetLatestMessages) g.POST("/messages", h.Widget.SendMessage) @@ -1726,9 +1726,9 @@ func registerChatwootWidgetRoutes(g *gin.RouterGroup, h *Handlers) { g.POST("/contact/destroy_custom_attributes", h.Widget.DestroyContactCustomAttributes) g.PATCH("/contact/set_user", chatwootParityStub) - g.GET("/inbox_members", chatwootParityStub) - g.POST("/labels", chatwootParityStub) - g.DELETE("/labels/:label_id", chatwootParityStub) + g.GET("/inbox_members", h.Widget.ListInboxMembers) + g.POST("/labels", h.Widget.AddLabel) + g.DELETE("/labels/:label_id", h.Widget.RemoveLabel) g.POST("/integrations/dyte/add_participant_to_meeting", chatwootParityStub) } diff --git a/internal/service/widget_offline_message_test.go b/internal/service/widget_offline_message_test.go index 13d57760..3beb3955 100644 --- a/internal/service/widget_offline_message_test.go +++ b/internal/service/widget_offline_message_test.go @@ -18,8 +18,12 @@ import ( // mockTypingIndicator satisfies the TypingIndicator interface for tests. type mockTypingIndicator struct{} -func (m *mockTypingIndicator) SetTypingOn(_ context.Context, _, _ uint, _ *ws.Performer) error { return nil } -func (m *mockTypingIndicator) SetTypingOff(_ context.Context, _, _ uint, _ *ws.Performer) error { return nil } +func (m *mockTypingIndicator) SetTypingOn(_ context.Context, _, _ uint, _ *ws.Performer) error { + return nil +} +func (m *mockTypingIndicator) SetTypingOff(_ context.Context, _, _ uint, _ *ws.Performer) error { + return nil +} // setupWidgetOfflineMessageTest creates an in-memory SQLite DB, migrates all // models needed by WidgetService, constructs repos + service, and returns them. @@ -70,6 +74,9 @@ func setupWidgetOfflineMessageTest(t *testing.T) (*gorm.DB, *WidgetService) { preChatFormRepo, fileUploadRepo, offlineMsgRepo, + nil, + nil, + nil, ) return db, svc @@ -282,11 +289,11 @@ func TestWidgetService_MarkOfflineMessageConverted(t *testing.T) { // Create a conversation to link conversation := &model.Conversation{ - AccountID: account.ID, - InboxID: inbox.ID, - Status: "open", - ChannelType: inbox.ChannelType, - Channel: inbox.ChannelType, + AccountID: account.ID, + InboxID: inbox.ID, + Status: "open", + ChannelType: inbox.ChannelType, + Channel: inbox.ChannelType, } require.NoError(t, db.Create(conversation).Error) @@ -523,4 +530,4 @@ func TestWidgetService_OfflineMessageDismissLifecycle(t *testing.T) { _, total, err := svc.ListOfflineMessagesByAccount(ctx, account.ID, 1, 10) require.NoError(t, err) assert.Equal(t, int64(2), total) -} \ No newline at end of file +} diff --git a/internal/service/widget_service.go b/internal/service/widget_service.go index c1a6d2b5..e48884af 100644 --- a/internal/service/widget_service.go +++ b/internal/service/widget_service.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "strconv" + "strings" "time" "github.com/gochat/gochat/internal/model" @@ -45,6 +46,9 @@ type WidgetService struct { preChatFormRepo *repository.PreChatFormRepo fileUploadRepo *repository.WidgetFileUploadRepo offlineMessageRepo *repository.WidgetOfflineMessageRepo + inboxMemberRepo *repository.InboxMemberRepo + tagRepo *repository.TagRepo + campaignRepo *repository.CampaignRepo } // NewWidgetService creates a new Widget service. @@ -59,6 +63,9 @@ func NewWidgetService( preChatFormRepo *repository.PreChatFormRepo, fileUploadRepo *repository.WidgetFileUploadRepo, offlineMessageRepo *repository.WidgetOfflineMessageRepo, + inboxMemberRepo *repository.InboxMemberRepo, + tagRepo *repository.TagRepo, + campaignRepo *repository.CampaignRepo, ) *WidgetService { return &WidgetService{ inboxRepo: inboxRepo, @@ -71,6 +78,9 @@ func NewWidgetService( preChatFormRepo: preChatFormRepo, fileUploadRepo: fileUploadRepo, offlineMessageRepo: offlineMessageRepo, + inboxMemberRepo: inboxMemberRepo, + tagRepo: tagRepo, + campaignRepo: campaignRepo, } } @@ -123,6 +133,21 @@ type WidgetContactUpdate struct { CustomAttributes map[string]any } +type WidgetInboxMember struct { + ID uint `json:"id"` + Name string `json:"name"` + AvatarURL string `json:"avatar_url"` + AvailabilityStatus string `json:"availability_status"` +} + +type WidgetCampaign struct { + ID uint `json:"id"` + TriggerRules map[string]any `json:"trigger_rules"` + TriggerOnlyDuringBusinessHours bool `json:"trigger_only_during_business_hours"` + Message string `json:"message"` + Sender any `json:"sender"` +} + // WidgetGetCableTokenResponse returns the pubsub_token for WebSocket connection. // Reference: Chatwoot widget SDK — fetches token for ActionCable subscription type WidgetGetCableTokenResponse struct { @@ -316,6 +341,141 @@ func (s *WidgetService) GetLatestConversationMessages(ctx context.Context, widge return messages, total, conversation, err } +func (s *WidgetService) GetInboxMembersByWebsiteToken(ctx context.Context, websiteToken string) ([]WidgetInboxMember, error) { + inbox, err := s.GetInboxByWebsiteToken(ctx, websiteToken) + if err != nil { + return nil, err + } + if s.inboxMemberRepo == nil { + return []WidgetInboxMember{}, nil + } + members, err := s.inboxMemberRepo.FindByInbox(ctx, inbox.ID) + if err != nil { + return nil, err + } + payload := make([]WidgetInboxMember, 0, len(members)) + for _, member := range members { + if member.User.ID == 0 { + continue + } + name := member.User.Name + if name == "" { + name = member.User.Email + } + availability := member.AvailabilityStatus + if availability == "" { + availability = "offline" + } + payload = append(payload, WidgetInboxMember{ + ID: member.User.ID, + Name: name, + AvatarURL: member.User.AvatarURL, + AvailabilityStatus: availability, + }) + } + return payload, nil +} + +func (s *WidgetService) GetCampaignsByWebsiteToken(ctx context.Context, websiteToken string) ([]WidgetCampaign, error) { + inbox, err := s.GetInboxByWebsiteToken(ctx, websiteToken) + if err != nil { + return nil, err + } + if s.campaignRepo == nil { + return []WidgetCampaign{}, nil + } + campaigns, err := s.campaignRepo.ListByInbox(ctx, inbox.AccountID, inbox.ID) + if err != nil { + return nil, err + } + payload := make([]WidgetCampaign, 0, len(campaigns)) + for _, campaign := range campaigns { + if !campaign.Enabled { + continue + } + id := campaign.DisplayID + if id == 0 { + id = campaign.ID + } + payload = append(payload, WidgetCampaign{ + ID: id, + TriggerRules: jsonMapBytes([]byte(campaign.TriggerRules)), + TriggerOnlyDuringBusinessHours: campaign.TriggerOnlyDuringBusinessHours, + Message: campaign.Message, + Sender: nil, + }) + } + return payload, nil +} + +func (s *WidgetService) TrackEvent(ctx context.Context, websiteToken, widgetToken, name string, eventInfo map[string]any) error { + if websiteToken == "" { + return errors.New("website_token is required") + } + if name == "" { + return errors.New("event name is required") + } + inbox, err := s.GetInboxByWebsiteToken(ctx, websiteToken) + if err != nil { + return err + } + if widgetToken == "" { + return errors.New("widget_token required") + } + contactInbox, err := s.contactInboxRepo.FindByPubsubToken(ctx, widgetToken) + if err != nil { + return fmt.Errorf("invalid widget_token: %w", err) + } + if contactInbox.InboxID != inbox.ID { + return errors.New("widget_token does not belong to this inbox") + } + _ = eventInfo + return nil +} + +func (s *WidgetService) AddLabelToLatestConversation(ctx context.Context, widgetToken, label string) error { + label = strings.TrimSpace(label) + if label == "" { + return nil + } + conversation, err := s.GetLatestConversation(ctx, widgetToken) + if err != nil { + return err + } + if s.tagRepo != nil { + if _, err := s.tagRepo.FindByNameAndAccountID(ctx, conversation.AccountID, label); err != nil { + return nil + } + } + labels := splitWidgetLabels(conversation.Labels) + for _, existing := range labels { + if existing == label { + return nil + } + } + labels = append(labels, label) + return s.conversationRepo.UpdateLabels(ctx, conversation.ID, strings.Join(labels, ",")) +} + +func (s *WidgetService) RemoveLabelFromLatestConversation(ctx context.Context, widgetToken, label string) error { + label = strings.TrimSpace(label) + if label == "" { + return nil + } + conversation, err := s.GetLatestConversation(ctx, widgetToken) + if err != nil { + return err + } + labels := splitWidgetLabels(conversation.Labels) + filtered := labels[:0] + for _, existing := range labels { + if existing != label { + filtered = append(filtered, existing) + } + } + return s.conversationRepo.UpdateLabels(ctx, conversation.ID, strings.Join(filtered, ",")) +} + // GetMessages returns messages for a conversation belonging to a widget contact. func (s *WidgetService) GetMessages(ctx context.Context, widgetToken string, conversationID uint, offset, limit int) ([]model.Message, int64, error) { contactInbox, err := s.contactInboxRepo.FindByPubsubToken(ctx, widgetToken) @@ -822,6 +982,10 @@ func (s *WidgetService) CountPendingOfflineMessages(ctx context.Context, inboxID } func jsonMap(raw datatypes.JSON) map[string]any { + return jsonMapBytes(raw) +} + +func jsonMapBytes(raw []byte) map[string]any { out := map[string]any{} if len(raw) == 0 { return out @@ -843,3 +1007,18 @@ func mustJSON(value map[string]any) datatypes.JSON { } return datatypes.JSON(b) } + +func splitWidgetLabels(raw string) []string { + if raw == "" { + return []string{} + } + parts := strings.Split(raw, ",") + labels := make([]string, 0, len(parts)) + for _, part := range parts { + label := strings.TrimSpace(part) + if label != "" { + labels = append(labels, label) + } + } + return labels +} diff --git a/internal/service/widget_service_test.go b/internal/service/widget_service_test.go index 37283056..217760f6 100644 --- a/internal/service/widget_service_test.go +++ b/internal/service/widget_service_test.go @@ -72,6 +72,9 @@ func setupWidgetServiceTest(t *testing.T) (*gorm.DB, *WidgetService) { preChatFormRepo, fileUploadRepo, offlineMsgRepo, + nil, + nil, + nil, ) return db, svc @@ -681,12 +684,12 @@ func TestWidgetService_GetThemeConfig(t *testing.T) { // Create a theme config newTheme := &model.WidgetThemeConfig{ - InboxID: inbox.ID, - PrimaryColor: "#ff0000", + InboxID: inbox.ID, + PrimaryColor: "#ff0000", BackgroundColor: "#000000", - TextColor: "#ffffff", - FontFamily: "Arial, sans-serif", - WidgetPosition: "left", + TextColor: "#ffffff", + FontFamily: "Arial, sans-serif", + WidgetPosition: "left", } created, err := svc.UpdateThemeConfig(ctx, inbox.ID, newTheme) require.NoError(t, err) @@ -1439,4 +1442,4 @@ func TestWidgetService_ConvertOfflineMessageToConversation_DismissedMessage(t *t assert.Nil(t, message) assert.Error(t, err) assert.Contains(t, err.Error(), "not pending") -} \ No newline at end of file +}