feat(widget): cover campaigns events and labels
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user