feat(widget): cover campaigns events and labels

This commit is contained in:
2026-06-04 21:45:49 +08:00
parent 67a6ae762e
commit f3b450905f
9 changed files with 434 additions and 22 deletions
+1
View File
@@ -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.
+1 -1
View File
@@ -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)
+78
View File
@@ -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,
+142 -1
View File
@@ -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) {
@@ -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))
}
}
+5 -5
View File
@@ -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)
}
@@ -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)
}
}
+179
View File
@@ -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
}
+9 -6
View File
@@ -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")
}
}