From d16bb55d913e78a4e85e95e266161a038031a611 Mon Sep 17 00:00:00 2001 From: Rogee Date: Mon, 13 Jul 2026 14:43:03 +0800 Subject: [PATCH] feat(realtime): complete fake channel reply flow --- backend/internal/channel/provider/fake.go | 46 +- .../internal/channel/provider/fake_test.go | 34 +- backend/internal/config/config.go | 2 + .../handler/api/v1/conversation_handler.go | 25 + .../handler/api/v1/conversation_serializer.go | 2 + .../api/v1/conversation_serializer_test.go | 10 + .../internal/service/conversation_service.go | 11 +- backend/internal/wsevent/bridge_listener.go | 193 +- .../internal/wsevent/bridge_listener_test.go | 148 ++ channels/fake/README.md | 14 +- channels/fake/src/client/gochat-client.ts | 4 + channels/fake/src/index.ts | 14 + channels/fake/src/server.ts | 92 +- channels/fake/src/store/memory-store.ts | 2 +- channels/fake/src/types.ts | 5 + channels/fake/tests/integration.test.ts | 38 +- docs/parity/frontend-smoke-report.md | 2 +- .../components-next/sidebar/Sidebar.vue | 1613 +++++++++-------- .../dashboard/components/ChatList.vue | 3 - .../store/modules/conversations/getters.js | 2 +- .../conversations/helpers/actionHelpers.js | 1 - .../store/modules/conversations/index.js | 2 + .../specs/conversations/mutations.spec.js | 16 + package.json | 2 +- 24 files changed, 1441 insertions(+), 840 deletions(-) create mode 100644 backend/internal/wsevent/bridge_listener_test.go diff --git a/backend/internal/channel/provider/fake.go b/backend/internal/channel/provider/fake.go index 69c134cd..05a2b3e5 100644 --- a/backend/internal/channel/provider/fake.go +++ b/backend/internal/channel/provider/fake.go @@ -110,16 +110,16 @@ func (p *FakeProvider) OnDestroy(ctx context.Context, inbox *model.Inbox, config // FakeIncomingPayload is the JSON body FakeMessagePlatform posts to // /webhooks/fake/:identifier. type FakeIncomingPayload struct { - Event string `json:"event"` - MessageID string `json:"message_id"` - SenderID string `json:"sender_id"` - SenderName string `json:"sender_name"` - Content string `json:"content"` - ContentType string `json:"content_type"` - ConversationID string `json:"conversation_id,omitempty"` - ReplyToID string `json:"reply_to_id,omitempty"` - Timestamp int64 `json:"timestamp,omitempty"` - Attachments []FakeAttachment `json:"attachments,omitempty"` + Event string `json:"event"` + MessageID string `json:"message_id"` + SenderID string `json:"sender_id"` + SenderName string `json:"sender_name"` + Content string `json:"content"` + ContentType string `json:"content_type"` + ConversationID string `json:"conversation_id,omitempty"` + ReplyToID string `json:"reply_to_id,omitempty"` + Timestamp int64 `json:"timestamp,omitempty"` + Attachments []FakeAttachment `json:"attachments,omitempty"` } // FakeAttachment mirrors channel.Attachment for the fake payload. @@ -218,11 +218,12 @@ func (p *FakeProvider) ValidateWebhookRequest(ctx context.Context, inbox *model. // FakeOutboundPayload is the JSON body FakeProvider.SendMessage posts to the // FakeMessagePlatform /receive endpoint. type FakeOutboundPayload struct { - MessageID uint `json:"message_id"` - ConversationID uint `json:"conversation_id"` - Content string `json:"content"` - ContentType string `json:"content_type"` - Sender FakeSender `json:"sender"` + MessageID uint `json:"message_id"` + ConversationID uint `json:"conversation_id"` + Content string `json:"content"` + ContentType string `json:"content_type"` + Sender FakeSender `json:"sender"` + Recipient FakeRecipient `json:"recipient"` } type FakeSender struct { @@ -231,6 +232,12 @@ type FakeSender struct { Type string `json:"type"` } +type FakeRecipient struct { + ID uint `json:"id"` + Name string `json:"name"` + SourceID string `json:"source_id"` +} + func (p *FakeProvider) SendMessage(ctx context.Context, inbox *model.Inbox, message *model.Message, contact *model.Contact) (*channel.SendResult, error) { config := parseFakeConfig(inbox) webhookURL, _ := config["webhook_url"].(string) @@ -249,9 +256,13 @@ func (p *FakeProvider) SendMessage(ctx context.Context, inbox *model.Inbox, mess if message.SenderID != nil { sender.ID = *message.SenderID } - // Prefer contact name when available, fall back to empty. + recipient := FakeRecipient{} if contact != nil { - sender.Name = contact.Name + recipient = FakeRecipient{ + ID: contact.ID, + Name: contact.Name, + SourceID: contact.Identifier, + } } payload := FakeOutboundPayload{ @@ -260,6 +271,7 @@ func (p *FakeProvider) SendMessage(ctx context.Context, inbox *model.Inbox, mess Content: message.Content, ContentType: message.ContentType, Sender: sender, + Recipient: recipient, } body, err := json.Marshal(payload) diff --git a/backend/internal/channel/provider/fake_test.go b/backend/internal/channel/provider/fake_test.go index d65b91b2..728c9dba 100644 --- a/backend/internal/channel/provider/fake_test.go +++ b/backend/internal/channel/provider/fake_test.go @@ -207,11 +207,11 @@ func TestFakeProvider_SendMessage_NoWebhookURL(t *testing.T) { inbox := &model.Inbox{} inbox.ID = 1 msg := &model.Message{ - Base: model.Base{ID: 100}, + Base: model.Base{ID: 100}, ConversationID: 50, - Content: "test", - ContentType: "text", - SenderType: "agent", + Content: "test", + ContentType: "text", + SenderType: "agent", } result, err := p.SendMessage(context.Background(), inbox, msg, nil) @@ -247,14 +247,18 @@ func TestFakeProvider_SendMessage_PostsToWebhookURL(t *testing.T) { senderID := uint(5) msg := &model.Message{ - Base: model.Base{ID: 100}, + Base: model.Base{ID: 100}, ConversationID: 50, - Content: "Hello from agent", - ContentType: "text", - SenderType: "agent", - SenderID: &senderID, + Content: "Hello from agent", + ContentType: "text", + SenderType: "agent", + SenderID: &senderID, + } + contact := &model.Contact{ + Base: model.Base{ID: 9}, + Name: "Customer Chen", + Identifier: "customer_001", } - contact := &model.Contact{Name: "Agent Wang"} result, err := p.SendMessage(context.Background(), inbox, msg, contact) if err != nil { @@ -278,6 +282,16 @@ func TestFakeProvider_SendMessage_PostsToWebhookURL(t *testing.T) { if sender["type"] != "agent" { t.Fatalf("expected sender.type 'agent', got %v", sender["type"]) } + recipient, ok := receivedBody["recipient"].(map[string]interface{}) + if !ok { + t.Fatal("expected recipient object") + } + if recipient["source_id"] != "customer_001" { + t.Fatalf("expected recipient.source_id 'customer_001', got %v", recipient["source_id"]) + } + if recipient["name"] != "Customer Chen" { + t.Fatalf("expected recipient.name 'Customer Chen', got %v", recipient["name"]) + } } func TestFakeProvider_GetContactProfile(t *testing.T) { diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index da195579..b5baedde 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -760,6 +760,8 @@ func parseDotEnvLine(line string) (string, string, bool) { val := strings.TrimSpace(parts[1]) if len(val) >= 2 && ((val[0] == '"' && val[len(val)-1] == '"') || (val[0] == '\'' && val[len(val)-1] == '\'')) { val = val[1 : len(val)-1] + } else if strings.HasPrefix(val, "#") { + val = "" } else if comment := strings.Index(val, " #"); comment >= 0 { val = strings.TrimSpace(val[:comment]) } diff --git a/backend/internal/handler/api/v1/conversation_handler.go b/backend/internal/handler/api/v1/conversation_handler.go index 6ec1a924..11f7065d 100644 --- a/backend/internal/handler/api/v1/conversation_handler.go +++ b/backend/internal/handler/api/v1/conversation_handler.go @@ -4,12 +4,14 @@ import ( "context" "errors" "io" + "net" "net/http" "strconv" "strings" "github.com/gin-gonic/gin" + "github.com/gochat/gochat/internal/llm" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/service" "github.com/gochat/gochat/pkg/pagination" @@ -1055,6 +1057,29 @@ func handleServiceError(c *gin.Context, err error) { return } errMsg := err.Error() + if errors.Is(err, llm.ErrProviderNotConfigured) { + response.AbortWithStatusError(c, http.StatusServiceUnavailable, response.ErrCopilotNotConfigured, errMsg) + return + } + var providerErr *llm.APIError + if errors.As(err, &providerErr) { + switch providerErr.StatusCode { + case http.StatusUnauthorized, http.StatusForbidden: + response.AbortWithStatusError(c, http.StatusBadGateway, response.ErrCopilotProviderAuth, "Copilot provider authentication failed") + case http.StatusNotFound: + response.AbortWithStatusError(c, http.StatusBadGateway, response.ErrCopilotModelNotFound, "Copilot provider endpoint or model was not found") + case http.StatusTooManyRequests: + response.AbortWithStatusError(c, http.StatusTooManyRequests, response.ErrCopilotProviderRateLimited, "Copilot provider rate limit exceeded") + default: + response.AbortWithStatusError(c, http.StatusBadGateway, response.ErrCopilotProviderUnreachable, "Copilot provider request failed") + } + return + } + var networkErr net.Error + if errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &networkErr) && networkErr.Timeout()) { + response.AbortWithStatusError(c, http.StatusGatewayTimeout, response.ErrCopilotProviderTimeout, "Copilot provider request timed out") + return + } lower := strings.ToLower(errMsg) if strings.Contains(lower, "not found") || strings.Contains(lower, "record not found") { response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, errMsg) diff --git a/backend/internal/handler/api/v1/conversation_serializer.go b/backend/internal/handler/api/v1/conversation_serializer.go index 57ee911f..3c1ee981 100644 --- a/backend/internal/handler/api/v1/conversation_serializer.go +++ b/backend/internal/handler/api/v1/conversation_serializer.go @@ -694,6 +694,7 @@ func serializeContactWithContext(ctx context.Context, contact *model.Contact) ma "custom_attributes": jsonObject(contact.CustomAttributes), "last_activity_at": int64Value(contact.LastActivityAt), "created_at": contact.CreatedAt.Unix(), + "type": "contact", } } @@ -710,6 +711,7 @@ func serializeUser(user *model.User, accountID uint) map[string]any { "name": user.Name, "role": nonEmpty(user.Role, "agent"), "thumbnail": user.AvatarURL, + "type": "user", } if attrs := jsonObject(user.CustomAttributes); len(attrs) > 0 { payload["custom_attributes"] = attrs diff --git a/backend/internal/handler/api/v1/conversation_serializer_test.go b/backend/internal/handler/api/v1/conversation_serializer_test.go index 72259162..25b2aaec 100644 --- a/backend/internal/handler/api/v1/conversation_serializer_test.go +++ b/backend/internal/handler/api/v1/conversation_serializer_test.go @@ -390,9 +390,19 @@ func TestSerializeContactUsesPresenceStatus(t *testing.T) { payload := serializeContactWithContext(ctx, contact) require.Equal(t, "online", payload["availability_status"]) + require.Equal(t, "contact", payload["type"]) require.Equal(t, "offline", serializeContact(contact)["availability_status"]) } +func TestSerializeUserIncludesSenderType(t *testing.T) { + user := &model.User{AccountID: 7, Name: "Support Agent"} + user.ID = 43 + + payload := serializeUser(user, user.AccountID) + + require.Equal(t, "user", payload["type"]) +} + func TestSerializeCRMContactUsesPresenceStatus(t *testing.T) { contact := &model.Contact{AccountID: 9, Name: "CRM Online Contact"} contact.ID = 99 diff --git a/backend/internal/service/conversation_service.go b/backend/internal/service/conversation_service.go index 21e1f6be..2dff5316 100644 --- a/backend/internal/service/conversation_service.go +++ b/backend/internal/service/conversation_service.go @@ -1759,10 +1759,19 @@ func (s *ConversationService) ToggleTyping(ctx context.Context, accountID, conve if !ok { return nil } - event := channel.NewChannelEvent(eventType, channel.ChannelAPI, accountID, conversation.InboxID) + channelType := channel.ChannelType(conversation.ChannelType) + if channelType == "" { + channelType = channel.ChannelAPI + } + event := channel.NewChannelEvent(eventType, channelType, accountID, conversation.InboxID) event.ConversationID = conversationID event.ContactID = conversation.ContactID event.UserID = userID + event.Data["conversation"] = conversation + var user model.User + if err := s.repo.DB().WithContext(ctx).First(&user, userID).Error; err == nil { + event.Data["user"] = &user + } event.Data["typing_status"] = typingStatus event.Data["is_private"] = isPrivate s.dispatcher.Dispatch(ctx, event) diff --git a/backend/internal/wsevent/bridge_listener.go b/backend/internal/wsevent/bridge_listener.go index 448377ac..c782b51c 100644 --- a/backend/internal/wsevent/bridge_listener.go +++ b/backend/internal/wsevent/bridge_listener.go @@ -9,8 +9,11 @@ package wsevent import ( "context" + "encoding/json" + "strings" "github.com/gochat/gochat/internal/channel" + "github.com/gochat/gochat/internal/model" wspkg "github.com/gochat/gochat/internal/ws" applogger "github.com/gochat/gochat/pkg/logger" ) @@ -51,13 +54,13 @@ func (l *BridgeListener) OnEvent(ctx context.Context, event *channel.ChannelEven return nil } - payload := event.Data - if payload == nil { - payload = map[string]interface{}{} - } + payload := wsEventPayload(event) + payload["account_id"] = event.AccountID if event.ConversationID != 0 { - payload["conversation_id"] = event.ConversationID + if _, exists := payload["conversation_id"]; !exists { + payload["conversation_id"] = event.ConversationID + } } if event.InboxID != 0 { payload["inbox_id"] = event.InboxID @@ -69,6 +72,186 @@ func (l *BridgeListener) OnEvent(ctx context.Context, event *channel.ChannelEven return nil } +// wsEventPayload converts internal dispatcher data into the flat push payload +// expected by the reused Chatwoot ActionCable client. Chatwoot broadcasts +// message.push_event_data directly, not an internal {message, conversation} +// wrapper. Keeping this normalization at the WS boundary lets other listeners +// continue consuming the richer internal event data. +func wsEventPayload(event *channel.ChannelEvent) map[string]interface{} { + if event == nil { + return map[string]interface{}{} + } + switch event.Type { + case channel.EventMessageCreated, channel.EventMessageUpdated, channel.EventMessageDeleted: + if message, ok := eventMessage(event.Data); ok { + return messagePushPayload(message, event.Data) + } + case channel.EventConversationCreated, channel.EventConversationUpdated, + channel.EventConversationOpened, channel.EventConversationResolved, + channel.EventConversationAssigned, channel.EventConversationUnassigned: + if conversation, ok := eventConversation(event.Data); ok { + return conversationPushPayload(conversation, event.Data) + } + case channel.EventContactCreated, channel.EventContactUpdated, channel.EventContactDeleted: + if contact, ok := eventContact(event.Data); ok { + return contactPushPayload(contact) + } + } + return copyEventData(event.Data) +} + +func conversationPushPayload(conversation *model.Conversation, data map[string]interface{}) map[string]interface{} { + payload := modelMap(conversation) + conversationID := conversation.ID + if conversation.DisplayID != nil && *conversation.DisplayID != 0 { + conversationID = *conversation.DisplayID + } + payload["id"] = conversationID + payload["created_at"] = conversation.CreatedAt.Unix() + payload["updated_at"] = float64(conversation.UpdatedAt.UnixNano()) / 1e9 + if conversation.LastActivityAt != nil { + payload["last_activity_at"] = *conversation.LastActivityAt + } + if _, exists := payload["messages"]; !exists { + payload["messages"] = []interface{}{} + } + meta := map[string]interface{}{} + if contact, ok := eventContact(data); ok { + meta["sender"] = contactPushPayload(contact) + } + if inbox, ok := eventInbox(data); ok { + meta["channel"] = inbox.ChannelType + } + payload["meta"] = meta + return payload +} + +func messagePushPayload(message *model.Message, data map[string]interface{}) map[string]interface{} { + payload := modelMap(message) + payload["created_at"] = message.CreatedAt.Unix() + payload["message_type"] = messageTypeValue(message.MessageType) + payload["content_type"] = nonEmpty(message.ContentType, "text") + payload["status"] = nonEmpty(message.Status, "sent") + if message.ContentAttributes == nil { + payload["content_attributes"] = map[string]interface{}{} + } + + conversationID := message.ConversationID + conversationPayload := map[string]interface{}{ + "last_activity_at": message.CreatedAt.Unix(), + } + if conversation, ok := eventConversation(data); ok { + if conversation.DisplayID != nil && *conversation.DisplayID != 0 { + conversationID = *conversation.DisplayID + } + conversationPayload["assignee_id"] = conversation.AssigneeID + if conversation.LastActivityAt != nil { + conversationPayload["last_activity_at"] = *conversation.LastActivityAt + } + } + if contact, ok := eventContact(data); ok { + payload["sender"] = contactPushPayload(contact) + conversationPayload["contact_inbox"] = map[string]interface{}{ + "source_id": contact.Identifier, + } + } + payload["conversation_id"] = conversationID + payload["conversation"] = conversationPayload + return payload +} + +func contactPushPayload(contact *model.Contact) map[string]interface{} { + payload := modelMap(contact) + payload["created_at"] = contact.CreatedAt.Unix() + payload["availability_status"] = "offline" + payload["type"] = "contact" + if contact.AdditionalAttributes == nil { + payload["additional_attributes"] = map[string]interface{}{} + } + if contact.CustomAttributes == nil { + payload["custom_attributes"] = map[string]interface{}{} + } + return payload +} + +func eventMessage(data map[string]interface{}) (*model.Message, bool) { + if message, ok := data["message"].(*model.Message); ok && message != nil { + return message, true + } + if message, ok := data["message"].(model.Message); ok { + return &message, true + } + return nil, false +} + +func eventConversation(data map[string]interface{}) (*model.Conversation, bool) { + if conversation, ok := data["conversation"].(*model.Conversation); ok && conversation != nil { + return conversation, true + } + if conversation, ok := data["conversation"].(model.Conversation); ok { + return &conversation, true + } + return nil, false +} + +func eventContact(data map[string]interface{}) (*model.Contact, bool) { + if contact, ok := data["contact"].(*model.Contact); ok && contact != nil { + return contact, true + } + if contact, ok := data["contact"].(model.Contact); ok { + return &contact, true + } + return nil, false +} + +func eventInbox(data map[string]interface{}) (*model.Inbox, bool) { + if inbox, ok := data["inbox"].(*model.Inbox); ok && inbox != nil { + return inbox, true + } + if inbox, ok := data["inbox"].(model.Inbox); ok { + return &inbox, true + } + return nil, false +} + +func modelMap(value interface{}) map[string]interface{} { + payload := map[string]interface{}{} + raw, err := json.Marshal(value) + if err != nil { + return payload + } + _ = json.Unmarshal(raw, &payload) + return payload +} + +func copyEventData(data map[string]interface{}) map[string]interface{} { + payload := make(map[string]interface{}, len(data)) + for key, value := range data { + payload[key] = value + } + return payload +} + +func messageTypeValue(value string) int { + switch strings.ToLower(strings.TrimSpace(value)) { + case "incoming": + return 0 + case "activity": + return 2 + case "template": + return 3 + default: + return 1 + } +} + +func nonEmpty(value, fallback string) string { + if strings.TrimSpace(value) == "" { + return fallback + } + return value +} + // isWSEventType returns true if the event type string corresponds to a // WebSocket/SSE event constant defined in wspkg. func isWSEventType(eventType string) bool { diff --git a/backend/internal/wsevent/bridge_listener_test.go b/backend/internal/wsevent/bridge_listener_test.go new file mode 100644 index 00000000..dc758082 --- /dev/null +++ b/backend/internal/wsevent/bridge_listener_test.go @@ -0,0 +1,148 @@ +package wsevent + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/gochat/gochat/internal/channel" + "github.com/gochat/gochat/internal/model" + wspkg "github.com/gochat/gochat/internal/ws" +) + +type captureHub struct { + accountID uint + data []byte +} + +func (h *captureHub) SendToAccount(accountID uint, data []byte) { + h.accountID = accountID + h.data = append([]byte(nil), data...) +} + +func (h *captureHub) SendToRoom(string, []byte) {} + +func TestBridgeListenerMessageCreatedUsesChatwootPushPayload(t *testing.T) { + now := time.Unix(1_783_834_149, 0) + lastActivity := now.Unix() + displayID := uint(22) + contactID := uint(9) + message := &model.Message{ + Base: model.Base{ID: 12, CreatedAt: now}, + AccountID: 1, + InboxID: 4, + ConversationID: 2, + SenderID: &contactID, + SenderType: string(model.SenderTypeContact), + Content: "hellohello", + ContentType: "text", + Status: "sent", + MessageType: "incoming", + SourceID: "fake_echo_11", + } + conversation := &model.Conversation{ + Base: model.Base{ID: 2, CreatedAt: now}, + AccountID: 1, + InboxID: 4, + ContactID: contactID, + DisplayID: &displayID, + LastActivityAt: &lastActivity, + } + contact := &model.Contact{ + Base: model.Base{ID: contactID, CreatedAt: now}, + AccountID: 1, + Name: "Fake Customer", + Identifier: "customer_001", + } + event := channel.NewChannelEvent(channel.EventMessageCreated, channel.ChannelFake, 1, 4) + event.ConversationID = conversation.ID + event.Data["message"] = message + event.Data["conversation"] = conversation + event.Data["contact"] = contact + + hub := &captureHub{} + listener := New(wspkg.NewEventPublisherLocal(hub, nil)) + if err := listener.OnEvent(context.Background(), event); err != nil { + t.Fatalf("OnEvent failed: %v", err) + } + + var envelope map[string]interface{} + if err := json.Unmarshal(hub.data, &envelope); err != nil { + t.Fatalf("decode websocket envelope: %v", err) + } + payload, ok := envelope["data"].(map[string]interface{}) + if !ok { + t.Fatalf("expected object payload, got %#v", envelope["data"]) + } + if payload["account_id"] != float64(1) { + t.Fatalf("expected account_id=1, got %#v", payload["account_id"]) + } + if payload["id"] != float64(12) || payload["content"] != "hellohello" { + t.Fatalf("unexpected flattened message payload: %#v", payload) + } + if payload["message_type"] != float64(0) { + t.Fatalf("expected incoming message_type=0, got %#v", payload["message_type"]) + } + if payload["conversation_id"] != float64(displayID) { + t.Fatalf("expected display conversation id %d, got %#v", displayID, payload["conversation_id"]) + } + if _, nested := payload["message"]; nested { + t.Fatalf("message.created payload must be flat: %#v", payload) + } + sender, ok := payload["sender"].(map[string]interface{}) + if !ok || sender["name"] != "Fake Customer" || sender["type"] != "contact" { + t.Fatalf("expected contact sender payload, got %#v", payload["sender"]) + } + conversationData, ok := payload["conversation"].(map[string]interface{}) + if !ok || conversationData["last_activity_at"] != float64(lastActivity) { + t.Fatalf("expected conversation push payload, got %#v", payload["conversation"]) + } +} + +func TestBridgeListenerConversationUpdatedIncludesMeta(t *testing.T) { + now := time.Unix(1_783_834_149, 0) + contactID := uint(9) + conversation := &model.Conversation{ + Base: model.Base{ID: 2, CreatedAt: now, UpdatedAt: now}, + AccountID: 1, + InboxID: 4, + ContactID: contactID, + Status: "open", + } + contact := &model.Contact{ + Base: model.Base{ID: contactID, CreatedAt: now}, + AccountID: 1, + Name: "Fake Customer", + Identifier: "customer_001", + } + inbox := &model.Inbox{Base: model.Base{ID: 4}, AccountID: 1, ChannelType: "fake"} + event := channel.NewChannelEvent(channel.EventConversationUpdated, channel.ChannelFake, 1, 4) + event.ConversationID = conversation.ID + event.Data["conversation"] = conversation + event.Data["contact"] = contact + event.Data["inbox"] = inbox + + hub := &captureHub{} + listener := New(wspkg.NewEventPublisherLocal(hub, nil)) + if err := listener.OnEvent(context.Background(), event); err != nil { + t.Fatalf("OnEvent failed: %v", err) + } + + var envelope map[string]interface{} + if err := json.Unmarshal(hub.data, &envelope); err != nil { + t.Fatalf("decode websocket envelope: %v", err) + } + payload := envelope["data"].(map[string]interface{}) + meta, ok := payload["meta"].(map[string]interface{}) + if !ok { + t.Fatalf("expected conversation meta, got %#v", payload["meta"]) + } + if meta["channel"] != "fake" { + t.Fatalf("expected fake channel meta, got %#v", meta) + } + sender, ok := meta["sender"].(map[string]interface{}) + if !ok || sender["name"] != "Fake Customer" || sender["type"] != "contact" { + t.Fatalf("expected sender in conversation meta, got %#v", meta) + } +} diff --git a/channels/fake/README.md b/channels/fake/README.md index bbbb4e77..c4b4da04 100644 --- a/channels/fake/README.md +++ b/channels/fake/README.md @@ -8,8 +8,9 @@ FakeMessagePlatform simulates an external messaging channel. It: 1. Sends messages to GoChat's `/webhooks/fake/:identifier` webhook endpoint (simulating customer-initiated messages) 2. Receives GoChat's outbound messages at `/receive` (messages sent by agents via FakeProvider.SendMessage) -3. Tracks all sent/received messages and agent states in memory for test assertions -4. Provides REST API for test scripts to orchestrate message flows +3. Automatically echoes agent messages back to GoChat as the same customer, so reply flows can be tested from the dashboard +4. Tracks all sent/received messages and agent states in memory for test assertions +5. Provides REST API for test scripts to orchestrate message flows ## Quick start @@ -32,6 +33,8 @@ Environment variables: | `PORT` | `9100` | HTTP listen port | | `GOCHAT_WEBHOOK_URL` | `http://127.0.0.1:3000/webhooks/fake/fake_inbox_1` | GoChat webhook URL | | `GOCHAT_FAKE_TOKEN` | `(empty)` | X-Fake-Token shared secret | +| `FAKE_AUTO_REPLY` | `true` | Echo human-agent outbound messages back as customer messages | +| `FAKE_AUTO_REPLY_DELAY_MS` | `250` | Delay before sending the automatic echo reply | ## REST API @@ -56,8 +59,15 @@ Environment variables: Test script → FakeMessagePlatform → GoChat webhook → Broker → Pipeline → DB → WS push ↑ Agent reply → FakeProvider.SendMessage → POST /receive → FakeMessagePlatform store + ↓ +Customer echo ← GoChat fake webhook ← automatic same-content reply ``` +Automatic replies use the outbound message's recipient identity, so the echoed +message is appended to the same open customer conversation. Bot/system sender +types are ignored to prevent automation loops. Set `FAKE_AUTO_REPLY=false` to +restore record-only behavior. + ## Related - GoChat FakeProvider: `backend/internal/channel/provider/fake.go` diff --git a/channels/fake/src/client/gochat-client.ts b/channels/fake/src/client/gochat-client.ts index 38ae339d..fbd86acb 100644 --- a/channels/fake/src/client/gochat-client.ts +++ b/channels/fake/src/client/gochat-client.ts @@ -20,6 +20,10 @@ export class GoChatClient { this.token = token; } + getWebhookUrl(): string { + return this.webhookUrl; + } + async sendToGoChat(payload: GoChatWebhookPayload): Promise { const headers: Record = { 'Content-Type': 'application/json', diff --git a/channels/fake/src/index.ts b/channels/fake/src/index.ts index 8f1b8945..c71502b5 100644 --- a/channels/fake/src/index.ts +++ b/channels/fake/src/index.ts @@ -4,6 +4,8 @@ // PORT — HTTP listen port (default 9100) // GOCHAT_WEBHOOK_URL — GoChat webhook URL (default http://127.0.0.1:3000/webhooks/fake/fake_inbox_1) // GOCHAT_FAKE_TOKEN — X-Fake-Token shared secret (default empty, no verification) +// FAKE_AUTO_REPLY — echo agent messages back to GoChat (default true) +// FAKE_AUTO_REPLY_DELAY_MS — delay before echoing (default 250ms) import { createServer } from './server.js'; @@ -12,15 +14,27 @@ const gochatWebhookUrl = process.env.GOCHAT_WEBHOOK_URL || 'http://127.0.0.1:3000/webhooks/fake/fake_inbox_1'; const gochatFakeToken = process.env.GOCHAT_FAKE_TOKEN || ''; +const autoReplyEnabled = !['0', 'false', 'off'].includes( + (process.env.FAKE_AUTO_REPLY || 'true').toLowerCase() +); +const autoReplyDelayMs = Math.max( + 0, + parseInt(process.env.FAKE_AUTO_REPLY_DELAY_MS || '250', 10) || 0 +); const app = createServer({ port, gochatWebhookUrl, gochatFakeToken, + autoReplyEnabled, + autoReplyDelayMs, }); app.listen(port, () => { console.log(`[FakeMessagePlatform] listening on :${port}`); console.log(`[FakeMessagePlatform] GoChat webhook: ${gochatWebhookUrl}`); console.log(`[FakeMessagePlatform] Token: ${gochatFakeToken ? '(set)' : '(none)'}`); + console.log( + `[FakeMessagePlatform] Auto reply: ${autoReplyEnabled ? `enabled (${autoReplyDelayMs}ms)` : 'disabled'}` + ); }); diff --git a/channels/fake/src/server.ts b/channels/fake/src/server.ts index 355737ac..c0ba95d0 100644 --- a/channels/fake/src/server.ts +++ b/channels/fake/src/server.ts @@ -33,6 +33,8 @@ export interface ServerConfig { port: number; gochatWebhookUrl: string; gochatFakeToken: string; + autoReplyEnabled?: boolean; + autoReplyDelayMs?: number; } export function createServer(config: ServerConfig): express.Application { @@ -43,13 +45,24 @@ export function createServer(config: ServerConfig): express.Application { config.gochatWebhookUrl, config.gochatFakeToken ); + let autoReplyEnabled = config.autoReplyEnabled ?? true; + let autoReplyDelayMs = Math.max(0, config.autoReplyDelayMs ?? 250); // Allow runtime config updates app.post('/api/config', (req, res) => { - const { webhook_url, token } = req.body || {}; + const { webhook_url, token, auto_reply, auto_reply_delay_ms } = req.body || {}; if (webhook_url) gochatClient.setWebhookUrl(webhook_url); if (token !== undefined) gochatClient.setToken(token); - res.json({ status: 'ok' }); + if (auto_reply !== undefined) autoReplyEnabled = Boolean(auto_reply); + if (auto_reply_delay_ms !== undefined) { + const parsedDelay = Number(auto_reply_delay_ms); + if (Number.isFinite(parsedDelay)) autoReplyDelayMs = Math.max(0, parsedDelay); + } + res.json({ + status: 'ok', + auto_reply: autoReplyEnabled, + auto_reply_delay_ms: autoReplyDelayMs, + }); }); // --- Health --- @@ -281,7 +294,11 @@ export function createServer(config: ServerConfig): express.Application { // --- Platform status --- app.get('/api/status', (_req, res) => { - res.json(store.getStatus()); + res.json({ + ...store.getStatus(), + auto_reply: autoReplyEnabled, + auto_reply_delay_ms: autoReplyDelayMs, + }); }); // --- Reset all state --- @@ -298,8 +315,75 @@ export function createServer(config: ServerConfig): express.Application { return; } store.recordReceived(body); - res.json({ status: 'ok', message_id: body.message_id }); + + const senderType = body.sender?.type?.toLowerCase() || ''; + const recipientSourceId = body.recipient?.source_id?.trim() || ''; + const shouldAutoReply = + autoReplyEnabled && + Boolean(body.content) && + Boolean(recipientSourceId) && + (senderType === 'user' || senderType === 'agent'); + + let autoReplyMessageId: string | undefined; + if (shouldAutoReply) { + autoReplyMessageId = `fake_echo_${body.message_id}_${Date.now()}`; + const payload: GoChatWebhookPayload = { + event: 'message.incoming', + message_id: autoReplyMessageId, + sender_id: recipientSourceId, + sender_name: body.recipient?.name || recipientSourceId, + content: body.content, + content_type: body.content_type || 'text', + conversation_id: String(body.conversation_id), + reply_to_id: `fake_${body.message_id}`, + timestamp: Math.floor(Date.now() / 1000), + }; + + setTimeout(async () => { + try { + const response = await gochatClient.sendToGoChat(payload); + if (!response.ok) { + console.error( + `[FakeMessagePlatform] auto reply failed: GoChat returned HTTP ${response.status}` + ); + return; + } + store.recordSent({ + id: autoReplyMessageId!, + direction: 'incoming', + inbox_identifier: webhookIdentifier(gochatClient.getWebhookUrl()), + message_id: autoReplyMessageId!, + sender_id: recipientSourceId, + sender_name: payload.sender_name, + content: payload.content, + content_type: payload.content_type, + conversation_id: payload.conversation_id, + timestamp: payload.timestamp, + }); + } catch (err) { + console.error( + `[FakeMessagePlatform] auto reply failed: ${(err as Error).message}` + ); + } + }, autoReplyDelayMs); + } + + res.json({ + status: 'ok', + message_id: body.message_id, + auto_reply: shouldAutoReply, + auto_reply_message_id: autoReplyMessageId, + }); }); return app; } + +function webhookIdentifier(webhookUrl: string): string { + try { + const pathname = new URL(webhookUrl).pathname; + return pathname.split('/').filter(Boolean).pop() || ''; + } catch { + return ''; + } +} diff --git a/channels/fake/src/store/memory-store.ts b/channels/fake/src/store/memory-store.ts index 71010528..c47e59bb 100644 --- a/channels/fake/src/store/memory-store.ts +++ b/channels/fake/src/store/memory-store.ts @@ -40,7 +40,7 @@ export class MemoryStore { return [...this.receivedMessages]; } - getMessageById(id: string): MessageRecord | undefined { + getMessageById(id: string): MessageRecord | GoChatOutboundMessage | undefined { return this.sentMessages.find((m) => m.id === id) || this.receivedMessages.find((m) => `out_${m.message_id}` === id); } diff --git a/channels/fake/src/types.ts b/channels/fake/src/types.ts index 0e2dbbc1..68b6d0d5 100644 --- a/channels/fake/src/types.ts +++ b/channels/fake/src/types.ts @@ -68,6 +68,11 @@ export interface GoChatOutboundMessage { name: string; type: string; }; + recipient?: { + id: number; + name: string; + source_id: string; + }; } export interface AgentInfo { diff --git a/channels/fake/tests/integration.test.ts b/channels/fake/tests/integration.test.ts index 497479a6..24b56184 100644 --- a/channels/fake/tests/integration.test.ts +++ b/channels/fake/tests/integration.test.ts @@ -40,6 +40,8 @@ function startFakeServer(webhookUrl: string): Promise { port: 0, gochatWebhookUrl: webhookUrl, gochatFakeToken: '', + autoReplyEnabled: true, + autoReplyDelayMs: 0, }); return new Promise((resolve) => { const srv = app.listen(0, '127.0.0.1', () => { @@ -59,6 +61,16 @@ async function fakeRequest(path: string, method: string = 'GET', body?: any): Pr return { status: res.status, data: await res.json() }; } +async function waitFor(condition: () => boolean, timeoutMs: number = 1000): Promise { + const startedAt = Date.now(); + while (!condition()) { + if (Date.now() - startedAt > timeoutMs) { + throw new Error('condition was not met before timeout'); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + beforeAll(async () => { mockGochatServer = await startMockGochat(); fakeApp = await startFakeServer(`http://127.0.0.1:${mockGochatPort}/webhooks/fake/fake_test_1`); @@ -167,21 +179,45 @@ describe('FakeMessagePlatform', () => { }); describe('/receive', () => { - it('stores GoChat outbound messages', async () => { + it('stores GoChat outbound messages and echoes them back as the customer', async () => { const res = await fakeRequest('/receive', 'POST', { message_id: 123, conversation_id: 456, content: 'Hello from agent', content_type: 'text', sender: { id: 1, name: 'Agent Wang', type: 'agent' }, + recipient: { id: 9, name: 'Customer Chen', source_id: 'customer_001' }, }); expect(res.status).toBe(200); expect(res.data.message_id).toBe(123); + expect(res.data.auto_reply).toBe(true); const msgRes = await fakeRequest('/api/messages'); expect(msgRes.data.received).toHaveLength(1); expect(msgRes.data.received[0].content).toBe('Hello from agent'); expect(msgRes.data.received[0].sender.type).toBe('agent'); + + await waitFor(() => receivedByGochat.length === 1); + expect(receivedByGochat[0].event).toBe('message.incoming'); + expect(receivedByGochat[0].content).toBe('Hello from agent'); + expect(receivedByGochat[0].sender_id).toBe('customer_001'); + expect(receivedByGochat[0].sender_name).toBe('Customer Chen'); + expect(receivedByGochat[0].conversation_id).toBe('456'); + }); + + it('does not auto reply to bot messages', async () => { + const res = await fakeRequest('/receive', 'POST', { + message_id: 124, + conversation_id: 456, + content: 'Automated answer', + content_type: 'text', + sender: { id: 2, name: 'Bot', type: 'AgentBot' }, + recipient: { id: 9, name: 'Customer Chen', source_id: 'customer_001' }, + }); + expect(res.status).toBe(200); + expect(res.data.auto_reply).toBe(false); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(receivedByGochat).toHaveLength(0); }); }); diff --git a/docs/parity/frontend-smoke-report.md b/docs/parity/frontend-smoke-report.md index caef11b6..3d347366 100644 --- a/docs/parity/frontend-smoke-report.md +++ b/docs/parity/frontend-smoke-report.md @@ -39,7 +39,7 @@ scripts/parity_frontend_smoke.sh --enterprise-browser-smoke ## Backend - URL: http://127.0.0.1:13000 -- Command: `env GOCHAT_ENV=development GOCHAT_SERVER_HOST=127.0.0.1 GOCHAT_SERVER_PORT=13000 GOCHAT_SERVER_MODE=debug GOCHAT_SEARCH_ENGINE=meilisearch GOCHAT_SEARCH_HOST=http://127.0.0.1:17700 GOCHAT_SEARCH_API_KEY=gochat_dev GOCHAT_CAPTAIN_ENABLED=false go run ./cmd/gochat serve` +- Command: `env GOCHAT_ENV=development GOCHAT_SERVER_HOST=127.0.0.1 GOCHAT_SERVER_PORT=13000 GOCHAT_SERVER_MODE=debug GOCHAT_SEARCH_ENGINE=meilisearch GOCHAT_SEARCH_HOST=http://127.0.0.1:17700 GOCHAT_SEARCH_API_KEY=gochat_dev go run ./cmd/gochat serve` - Search: `meilisearch` at `http://127.0.0.1:17700` - Log: `/home/rogee/Projects/gochat/.tmp/frontend-smoke-live/gochat.log` diff --git a/frontend/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/frontend/app/javascript/dashboard/components-next/sidebar/Sidebar.vue index 8b5197c8..85e2a15c 100644 --- a/frontend/app/javascript/dashboard/components-next/sidebar/Sidebar.vue +++ b/frontend/app/javascript/dashboard/components-next/sidebar/Sidebar.vue @@ -23,17 +23,17 @@ import Logo from 'next/icon/Logo.vue'; import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue'; const props = defineProps({ - isMobileSidebarOpen: { - type: Boolean, - default: false, - }, + isMobileSidebarOpen: { + type: Boolean, + default: false, + }, }); const emit = defineEmits([ - 'closeKeyShortcutModal', - 'openKeyShortcutModal', - 'showCreateAccountModal', - 'closeMobileSidebar', + 'closeKeyShortcutModal', + 'openKeyShortcutModal', + 'showCreateAccountModal', + 'closeMobileSidebar', ]); const { accountScopedRoute, isOnChatwootCloud } = useAccount(); @@ -42,7 +42,7 @@ const searchShortcut = useKbd([`$mod`, 'k']); const { t } = useI18n(); const isACustomBrandedInstance = useMapGetter( - 'globalConfig/isACustomBrandedInstance' + 'globalConfig/isACustomBrandedInstance' ); const isRTL = useMapGetter('accounts/isRTL'); @@ -51,40 +51,40 @@ const isMobile = computed(() => windowWidth.value < 768); const accountId = useMapGetter('getCurrentAccountId'); const isFeatureEnabledonAccount = useMapGetter( - 'accounts/isFeatureEnabledonAccount' + 'accounts/isFeatureEnabledonAccount' ); const hasAdvancedAssignment = computed(() => { - return isFeatureEnabledonAccount.value( - accountId.value, - FEATURE_FLAGS.ADVANCED_ASSIGNMENT - ); + return isFeatureEnabledonAccount.value( + accountId.value, + FEATURE_FLAGS.ADVANCED_ASSIGNMENT + ); }); const hasConversationUnreadCounts = computed(() => { - return isFeatureEnabledonAccount.value( - accountId.value, - FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS - ); + return isFeatureEnabledonAccount.value( + accountId.value, + FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS + ); }); const fetchConversationUnreadCounts = ([currentAccountId, isEnabled]) => { - if (!currentAccountId) return; + if (!currentAccountId) return; - if (!isEnabled) { - store.dispatch('conversationUnreadCounts/clear'); - return; - } + if (!isEnabled) { + store.dispatch('conversationUnreadCounts/clear'); + return; + } - store.dispatch('conversationUnreadCounts/get'); + store.dispatch('conversationUnreadCounts/get'); }; const toggleShortcutModalFn = show => { - if (show) { - emit('openKeyShortcutModal'); - } else { - emit('closeKeyShortcutModal'); - } + if (show) { + emit('openKeyShortcutModal'); + } else { + emit('closeKeyShortcutModal'); + } }; useSidebarKeyboardShortcuts(toggleShortcutModalFn); @@ -92,22 +92,22 @@ useSidebarKeyboardShortcuts(toggleShortcutModalFn); const expandedItem = ref(null); const setExpandedItem = name => { - expandedItem.value = expandedItem.value === name ? null : name; + expandedItem.value = expandedItem.value === name ? null : name; }; const { - sidebarWidth, - isCollapsed, - setSidebarWidth, - saveWidth, - snapToCollapsed, - snapToExpanded, - COLLAPSED_THRESHOLD, + sidebarWidth, + isCollapsed, + setSidebarWidth, + saveWidth, + snapToCollapsed, + snapToExpanded, + COLLAPSED_THRESHOLD, } = useSidebarResize(); // On mobile, sidebar is always expanded (flyout mode) const isEffectivelyCollapsed = computed( - () => !isMobile.value && isCollapsed.value + () => !isMobile.value && isCollapsed.value ); // Resize handle logic @@ -116,55 +116,55 @@ const startX = ref(0); const startWidth = ref(0); provideSidebarContext({ - expandedItem, - setExpandedItem, - isCollapsed: isEffectivelyCollapsed, - sidebarWidth, - isResizing, + expandedItem, + setExpandedItem, + isCollapsed: isEffectivelyCollapsed, + sidebarWidth, + isResizing, }); // Get clientX from mouse or touch event const getClientX = event => - event.touches ? event.touches[0].clientX : event.clientX; + event.touches ? event.touches[0].clientX : event.clientX; const onResizeStart = event => { - isResizing.value = true; - startX.value = getClientX(event); - startWidth.value = sidebarWidth.value; - Object.assign(document.body.style, { - cursor: 'col-resize', - userSelect: 'none', - }); - // Prevent default to avoid scrolling on touch - event.preventDefault(); + isResizing.value = true; + startX.value = getClientX(event); + startWidth.value = sidebarWidth.value; + Object.assign(document.body.style, { + cursor: 'col-resize', + userSelect: 'none', + }); + // Prevent default to avoid scrolling on touch + event.preventDefault(); }; const onResizeMove = event => { - if (!isResizing.value) return; + if (!isResizing.value) return; - const delta = isRTL.value - ? startX.value - getClientX(event) - : getClientX(event) - startX.value; - setSidebarWidth(startWidth.value + delta); + const delta = isRTL.value + ? startX.value - getClientX(event) + : getClientX(event) - startX.value; + setSidebarWidth(startWidth.value + delta); }; const onResizeEnd = () => { - if (!isResizing.value) return; + if (!isResizing.value) return; - isResizing.value = false; - Object.assign(document.body.style, { cursor: '', userSelect: '' }); + isResizing.value = false; + Object.assign(document.body.style, { cursor: '', userSelect: '' }); - // Snap to collapsed state if below threshold - if (sidebarWidth.value < COLLAPSED_THRESHOLD) { - snapToCollapsed(); - } else { - saveWidth(); - } + // Snap to collapsed state if below threshold + if (sidebarWidth.value < COLLAPSED_THRESHOLD) { + snapToCollapsed(); + } else { + saveWidth(); + } }; const onResizeHandleDoubleClick = () => { - if (isCollapsed.value) snapToExpanded(); - else snapToCollapsed(); + if (isCollapsed.value) snapToExpanded(); + else snapToCollapsed(); }; // Support both mouse and touch events @@ -176,772 +176,801 @@ useEventListener(document, 'touchend', onResizeEnd); const inboxes = useMapGetter('inboxes/getInboxes'); const labels = useMapGetter('labels/getLabelsOnSidebar'); const getInboxUnreadCount = useMapGetter( - 'conversationUnreadCounts/getInboxUnreadCount' + 'conversationUnreadCounts/getInboxUnreadCount' ); const getLabelUnreadCount = useMapGetter( - 'conversationUnreadCounts/getLabelUnreadCount' + 'conversationUnreadCounts/getLabelUnreadCount' ); const getTeamUnreadCount = useMapGetter( - 'conversationUnreadCounts/getTeamUnreadCount' + 'conversationUnreadCounts/getTeamUnreadCount' ); const teams = useMapGetter('teams/getMyTeams'); const contactCustomViews = useMapGetter('customViews/getContactCustomViews'); const conversationCustomViews = useMapGetter( - 'customViews/getConversationCustomViews' + 'customViews/getConversationCustomViews' ); onMounted(() => { - store.dispatch('labels/get'); - store.dispatch('inboxes/get'); - store.dispatch('notifications/unReadCount'); - store.dispatch('teams/get'); - store.dispatch('attributes/get'); - store.dispatch('customViews/get', 'conversation'); - store.dispatch('customViews/get', 'contact'); + store.dispatch('labels/get'); + store.dispatch('inboxes/get'); + store.dispatch('notifications/unReadCount'); + store.dispatch('teams/get'); + store.dispatch('attributes/get'); + store.dispatch('customViews/get', 'conversation'); + store.dispatch('customViews/get', 'contact'); }); watch([accountId, hasConversationUnreadCounts], fetchConversationUnreadCounts, { - immediate: true, + immediate: true, }); const normalizeUnreadCount = count => { - const unreadCount = Number(count); - return Number.isFinite(unreadCount) && unreadCount > 0 ? unreadCount : 0; + const unreadCount = Number(count); + return Number.isFinite(unreadCount) && unreadCount > 0 ? unreadCount : 0; }; const sortByUnreadCount = (items, labelKey, unreadCountKey) => - items.slice().sort((a, b) => { - const unreadCountDiff = - normalizeUnreadCount(unreadCountKey(b)) - - normalizeUnreadCount(unreadCountKey(a)); + items.slice().sort((a, b) => { + const unreadCountDiff = + normalizeUnreadCount(unreadCountKey(b)) - + normalizeUnreadCount(unreadCountKey(a)); - if (unreadCountDiff !== 0) return unreadCountDiff; + if (unreadCountDiff !== 0) return unreadCountDiff; - return labelKey(a).localeCompare(labelKey(b)); - }); + return labelKey(a).localeCompare(labelKey(b)); + }); const sortedTeams = computed(() => - sortByUnreadCount( - teams.value, - team => team.name, - team => getTeamUnreadCount.value(team.id) - ) + sortByUnreadCount( + teams.value, + team => team.name, + team => getTeamUnreadCount.value(team.id) + ) ); const sortedInboxes = computed(() => - sortByUnreadCount( - inboxes.value, - inbox => inbox.name, - inbox => getInboxUnreadCount.value(inbox.id) - ) + sortByUnreadCount( + inboxes.value, + inbox => inbox.name, + inbox => getInboxUnreadCount.value(inbox.id) + ) ); const sortedLabels = computed(() => - sortByUnreadCount( - labels.value, - label => label.title, - label => getLabelUnreadCount.value(label.id) - ) + sortByUnreadCount( + labels.value, + label => label.title, + label => getLabelUnreadCount.value(label.id) + ) ); const closeMobileSidebar = () => { - if (!props.isMobileSidebarOpen) return; - emit('closeMobileSidebar'); + if (!props.isMobileSidebarOpen) return; + emit('closeMobileSidebar'); }; const newReportRoutes = () => [ - { - name: 'Reports Agent', - label: t('SIDEBAR.REPORTS_AGENT'), - to: accountScopedRoute('agent_reports_index'), - activeOn: ['agent_reports_show'], - }, - { - name: 'Reports Label', - label: t('SIDEBAR.REPORTS_LABEL'), - to: accountScopedRoute('label_reports_index'), - }, - { - name: 'Reports Inbox', - label: t('SIDEBAR.REPORTS_INBOX'), - to: accountScopedRoute('inbox_reports_index'), - activeOn: ['inbox_reports_show'], - }, - { - name: 'Reports Team', - label: t('SIDEBAR.REPORTS_TEAM'), - to: accountScopedRoute('team_reports_index'), - activeOn: ['team_reports_show'], - }, + { + name: 'Reports Agent', + label: t('SIDEBAR.REPORTS_AGENT'), + to: accountScopedRoute('agent_reports_index'), + activeOn: ['agent_reports_show'], + }, + { + name: 'Reports Label', + label: t('SIDEBAR.REPORTS_LABEL'), + to: accountScopedRoute('label_reports_index'), + }, + { + name: 'Reports Inbox', + label: t('SIDEBAR.REPORTS_INBOX'), + to: accountScopedRoute('inbox_reports_index'), + activeOn: ['inbox_reports_show'], + }, + { + name: 'Reports Team', + label: t('SIDEBAR.REPORTS_TEAM'), + to: accountScopedRoute('team_reports_index'), + activeOn: ['team_reports_show'], + }, ]; const reportRoutes = computed(() => newReportRoutes()); const menuItems = computed(() => { - return [ - { - name: 'Inbox', - label: t('SIDEBAR.INBOX'), - icon: 'i-lucide-inbox', - to: accountScopedRoute('inbox_view'), - activeOn: ['inbox_view', 'inbox_view_conversation'], - getterKeys: { - count: 'notifications/getUnreadCount', - }, - }, - { - name: 'Conversation', - label: t('SIDEBAR.CONVERSATIONS'), - icon: 'i-lucide-message-circle', - children: [ - { - name: 'All', - label: t('SIDEBAR.ALL_CONVERSATIONS'), - activeOn: ['inbox_conversation'], - to: accountScopedRoute('home'), - }, - { - name: 'Mentions', - label: t('SIDEBAR.MENTIONED_CONVERSATIONS'), - activeOn: ['conversation_through_mentions'], - to: accountScopedRoute('conversation_mentions'), - }, - { - name: 'Participating', - label: t('SIDEBAR.PARTICIPATING_CONVERSATIONS'), - activeOn: ['conversation_through_participating'], - to: accountScopedRoute('conversation_participating'), - }, - { - name: 'Unattended', - activeOn: ['conversation_through_unattended'], - label: t('SIDEBAR.UNATTENDED_CONVERSATIONS'), - to: accountScopedRoute('conversation_unattended'), - }, - { - name: 'Folders', - label: t('SIDEBAR.CUSTOM_VIEWS_FOLDER'), - icon: 'i-lucide-folder', - activeOn: ['conversations_through_folders'], - children: conversationCustomViews.value.map(view => ({ - name: `${view.name}-${view.id}`, - label: view.name, - to: accountScopedRoute('folder_conversations', { id: view.id }), - })), - }, - { - name: 'Teams', - label: t('SIDEBAR.TEAMS'), - icon: 'i-lucide-users', - activeOn: ['conversations_through_team'], - children: sortedTeams.value.map(team => ({ - name: `${team.name}-${team.id}`, - label: team.name, - badgeCount: getTeamUnreadCount.value(team.id), - to: accountScopedRoute('team_conversations', { teamId: team.id }), - })), - }, - { - name: 'Channels', - label: t('SIDEBAR.CHANNELS'), - icon: 'i-lucide-mailbox', - activeOn: ['conversation_through_inbox'], - children: sortedInboxes.value.map(inbox => ({ - name: `${inbox.name}-${inbox.id}`, - label: inbox.name, - badgeCount: getInboxUnreadCount.value(inbox.id), - icon: h(ChannelIcon, { inbox, class: 'size-[16px]' }), - to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }), - component: leafProps => - h(ChannelLeaf, { - label: leafProps.label, - active: leafProps.active, - inbox, - badgeCount: leafProps.badgeCount, - }), - })), - }, - { - name: 'Labels', - label: t('SIDEBAR.LABELS'), - icon: 'i-lucide-tag', - activeOn: ['conversations_through_label'], - children: sortedLabels.value.map(label => ({ - name: `${label.title}-${label.id}`, - label: label.title, - badgeCount: getLabelUnreadCount.value(label.id), - icon: h('span', { - class: `size-[8px] rounded-sm`, - style: { backgroundColor: label.color }, - }), - to: accountScopedRoute('label_conversations', { - label: label.title, - }), - })), - }, - ], - }, - { - name: 'Captain', - icon: 'i-woot-captain', - label: t('SIDEBAR.CAPTAIN'), - activeOn: ['captain_assistants_create_index'], - children: [ - { - name: 'FAQs', - label: t('SIDEBAR.CAPTAIN_RESPONSES'), - activeOn: [ - 'captain_assistants_responses_index', - 'captain_assistants_responses_pending', - ], - to: accountScopedRoute('captain_assistants_index', { - navigationPath: 'captain_assistants_responses_index', - }), - }, - { - name: 'Documents', - label: t('SIDEBAR.CAPTAIN_DOCUMENTS'), - activeOn: ['captain_assistants_documents_index'], - to: accountScopedRoute('captain_assistants_index', { - navigationPath: 'captain_assistants_documents_index', - }), - }, - { - name: 'Scenarios', - label: t('SIDEBAR.CAPTAIN_SCENARIOS'), - activeOn: ['captain_assistants_scenarios_index'], - to: accountScopedRoute('captain_assistants_index', { - navigationPath: 'captain_assistants_scenarios_index', - }), - }, - { - name: 'Playground', - label: t('SIDEBAR.CAPTAIN_PLAYGROUND'), - activeOn: ['captain_assistants_playground_index'], - to: accountScopedRoute('captain_assistants_index', { - navigationPath: 'captain_assistants_playground_index', - }), - }, - { - name: 'Inboxes', - label: t('SIDEBAR.CAPTAIN_INBOXES'), - activeOn: ['captain_assistants_inboxes_index'], - to: accountScopedRoute('captain_assistants_index', { - navigationPath: 'captain_assistants_inboxes_index', - }), - }, - { - name: 'Tools', - label: t('SIDEBAR.CAPTAIN_TOOLS'), - activeOn: ['captain_tools_index'], - to: accountScopedRoute('captain_assistants_index', { - navigationPath: 'captain_tools_index', - }), - }, - { - name: 'Settings', - label: t('SIDEBAR.CAPTAIN_SETTINGS'), - activeOn: [ - 'captain_assistants_settings_index', - 'captain_assistants_guidelines_index', - 'captain_assistants_guardrails_index', - ], - to: accountScopedRoute('captain_assistants_index', { - navigationPath: 'captain_assistants_settings_index', - }), - }, - ], - }, - { - name: 'Contacts', - label: t('SIDEBAR.CONTACTS'), - icon: 'i-lucide-contact', - children: [ - { - name: 'All Contacts', - label: t('SIDEBAR.ALL_CONTACTS'), - to: accountScopedRoute( - 'contacts_dashboard_index', - {}, - { page: 1, search: undefined } - ), - activeOn: ['contacts_dashboard_index', 'contacts_edit'], - }, - { - name: 'Active', - label: t('SIDEBAR.ACTIVE'), - to: accountScopedRoute('contacts_dashboard_active'), - activeOn: ['contacts_dashboard_active'], - }, - { - name: 'Segments', - icon: 'i-lucide-group', - label: t('SIDEBAR.CUSTOM_VIEWS_SEGMENTS'), - children: contactCustomViews.value.map(view => ({ - name: `${view.name}-${view.id}`, - label: view.name, - to: accountScopedRoute( - 'contacts_dashboard_segments_index', - { segmentId: view.id }, - { page: 1 } - ), - activeOn: [ - 'contacts_dashboard_segments_index', - 'contacts_edit_segment', - ], - })), - }, - { - name: 'Tagged With', - icon: 'i-lucide-tag', - label: t('SIDEBAR.TAGGED_WITH'), - children: labels.value.map(label => ({ - name: `${label.title}-${label.id}`, - label: label.title, - icon: h('span', { - class: `size-[8px] rounded-sm`, - style: { backgroundColor: label.color }, - }), - to: accountScopedRoute( - 'contacts_dashboard_labels_index', - { label: label.title }, - { page: 1, search: undefined } - ), - activeOn: [ - 'contacts_dashboard_labels_index', - 'contacts_edit_label', - ], - })), - }, - ], - }, - { - name: 'Companies', - label: t('SIDEBAR.COMPANIES'), - icon: 'i-lucide-building-2', - children: [ - { - name: 'All Companies', - label: t('SIDEBAR.ALL_COMPANIES'), - to: accountScopedRoute( - 'companies_dashboard_index', - {}, - { page: 1, search: undefined } - ), - activeOn: ['companies_dashboard_index', 'companies_dashboard_show'], - }, - ], - }, - { - name: 'Reports', - label: t('SIDEBAR.REPORTS'), - icon: 'i-lucide-chart-spline', - children: [ - { - name: 'Report Overview', - label: t('SIDEBAR.REPORTS_OVERVIEW'), - to: accountScopedRoute('account_overview_reports'), - }, - { - name: 'Report Conversation', - label: t('SIDEBAR.REPORTS_CONVERSATION'), - to: accountScopedRoute('conversation_reports'), - }, - ...reportRoutes.value, - { - name: 'Reports CSAT', - label: t('SIDEBAR.CSAT'), - to: accountScopedRoute('csat_reports'), - }, - { - name: 'Reports SLA', - label: t('SIDEBAR.REPORTS_SLA'), - to: accountScopedRoute('sla_reports'), - }, - { - name: 'Reports Bot', - label: t('SIDEBAR.REPORTS_BOT'), - to: accountScopedRoute('bot_reports'), - }, - ], - }, - { - name: 'Campaigns', - label: t('SIDEBAR.CAMPAIGNS'), - icon: 'i-lucide-megaphone', - children: [ - { - name: 'Live chat', - label: t('SIDEBAR.LIVE_CHAT'), - to: accountScopedRoute('campaigns_livechat_index'), - }, - { - name: 'SMS', - label: t('SIDEBAR.SMS'), - to: accountScopedRoute('campaigns_sms_index'), - }, - { - name: 'WhatsApp', - label: t('SIDEBAR.WHATSAPP'), - to: accountScopedRoute('campaigns_whatsapp_index'), - }, - ], - }, - { - name: 'Portals', - label: t('SIDEBAR.HELP_CENTER.TITLE'), - icon: 'i-lucide-library-big', - children: [ - { - name: 'Articles', - label: t('SIDEBAR.HELP_CENTER.ARTICLES'), - activeOn: [ - 'portals_articles_index', - 'portals_articles_new', - 'portals_articles_edit', - ], - to: accountScopedRoute('portals_index', { - navigationPath: 'portals_articles_index', - }), - }, - { - name: 'Categories', - label: t('SIDEBAR.HELP_CENTER.CATEGORIES'), - activeOn: [ - 'portals_categories_index', - 'portals_categories_articles_index', - 'portals_categories_articles_edit', - ], - to: accountScopedRoute('portals_index', { - navigationPath: 'portals_categories_index', - }), - }, - { - name: 'Locales', - label: t('SIDEBAR.HELP_CENTER.LOCALES'), - activeOn: ['portals_locales_index'], - to: accountScopedRoute('portals_index', { - navigationPath: 'portals_locales_index', - }), - }, - { - name: 'Settings', - label: t('SIDEBAR.HELP_CENTER.SETTINGS'), - activeOn: ['portals_settings_index'], - to: accountScopedRoute('portals_index', { - navigationPath: 'portals_settings_index', - }), - }, - ], - }, - { - name: 'Settings', - label: t('SIDEBAR.SETTINGS'), - icon: 'i-lucide-bolt', - children: [ - { - name: 'Settings Account Settings', - label: t('SIDEBAR.ACCOUNT_SETTINGS'), - icon: 'i-lucide-briefcase', - to: accountScopedRoute('general_settings_index'), - }, - { - name: 'Settings Copilot', - label: t('SIDEBAR.CAPTAIN_AI'), - icon: 'i-woot-captain', - to: accountScopedRoute('copilot_settings_index'), - }, - { - name: 'Settings Agents', - label: t('SIDEBAR.AGENTS'), - icon: 'i-lucide-square-user', - to: accountScopedRoute('agent_list'), - }, - { - name: 'Settings Teams', - label: t('SIDEBAR.TEAMS'), - icon: 'i-lucide-users', - activeOn: [ - 'settings_teams_list', - 'settings_teams_new', - 'settings_teams_finish', - 'settings_teams_add_agents', - 'settings_teams_show', - 'settings_teams_edit', - 'settings_teams_edit_members', - 'settings_teams_edit_finish', - ], - to: accountScopedRoute('settings_teams_list'), - }, - ...(hasAdvancedAssignment.value - ? [ - { - name: 'Settings Agent Assignment', - label: t('SIDEBAR.AGENT_ASSIGNMENT'), - icon: 'i-lucide-user-cog', - activeOn: [ - 'assignment_policy_index', - 'agent_assignment_policy_index', - 'agent_assignment_policy_create', - 'agent_assignment_policy_edit', - 'agent_capacity_policy_index', - 'agent_capacity_policy_create', - 'agent_capacity_policy_edit', - ], - to: accountScopedRoute('assignment_policy_index'), - }, - ] - : []), - { - name: 'Settings Inboxes', - label: t('SIDEBAR.INBOXES'), - icon: 'i-lucide-inbox', - activeOn: [ - 'settings_inbox_list', - 'settings_inbox_show', - 'settings_inbox_new', - 'settings_inbox_finish', - 'settings_inboxes_page_channel', - 'settings_inboxes_add_agents', - ], - to: accountScopedRoute('settings_inbox_list'), - }, - { - name: 'Settings Labels', - label: t('SIDEBAR.LABELS'), - icon: 'i-lucide-tags', - to: accountScopedRoute('labels_list'), - }, - { - name: 'Settings Custom Attributes', - label: t('SIDEBAR.CUSTOM_ATTRIBUTES'), - icon: 'i-lucide-code', - to: accountScopedRoute('attributes_list'), - }, - { - name: 'Settings Automation', - label: t('SIDEBAR.AUTOMATION'), - icon: 'i-lucide-repeat', - to: accountScopedRoute('automation_list'), - }, - { - name: 'Settings Agent Bots', - label: t('SIDEBAR.AGENT_BOTS'), - icon: 'i-lucide-bot', - to: accountScopedRoute('agent_bots'), - }, - { - name: 'Settings Macros', - label: t('SIDEBAR.MACROS'), - icon: 'i-lucide-toy-brick', - to: accountScopedRoute('macros_wrapper'), - }, - { - name: 'Settings Canned Responses', - label: t('SIDEBAR.CANNED_RESPONSES'), - icon: 'i-lucide-message-square-quote', - to: accountScopedRoute('canned_list'), - }, - { - name: 'Settings Integrations', - label: t('SIDEBAR.INTEGRATIONS'), - icon: 'i-lucide-blocks', - to: accountScopedRoute('settings_applications'), - }, - { - name: 'Settings Audit Logs', - label: t('SIDEBAR.AUDIT_LOGS'), - icon: 'i-lucide-briefcase', - to: accountScopedRoute('auditlogs_list'), - }, - { - name: 'Settings Custom Roles', - label: t('SIDEBAR.CUSTOM_ROLES'), - icon: 'i-lucide-shield-plus', - to: accountScopedRoute('custom_roles_list'), - }, - { - name: 'Settings Sla', - label: t('SIDEBAR.SLA'), - icon: 'i-lucide-clock-alert', - to: accountScopedRoute('sla_list'), - }, - { - name: 'Conversation Workflow', - label: t('SIDEBAR.CONVERSATION_WORKFLOW'), - icon: 'i-lucide-workflow', - to: accountScopedRoute('conversation_workflow_index'), - }, - { - name: 'Settings Security', - label: t('SIDEBAR.SECURITY'), - icon: 'i-lucide-shield', - to: accountScopedRoute('security_settings_index'), - }, - { - name: 'Settings Billing', - label: t('SIDEBAR.BILLING'), - icon: 'i-lucide-credit-card', - to: accountScopedRoute('billing_settings_index'), - }, - ], - }, - ]; + return [ + { + name: 'Inbox', + label: t('SIDEBAR.INBOX'), + icon: 'i-lucide-inbox', + to: accountScopedRoute('inbox_view'), + activeOn: ['inbox_view', 'inbox_view_conversation'], + getterKeys: { + count: 'notifications/getUnreadCount', + }, + }, + { + name: 'Conversation', + label: t('SIDEBAR.CONVERSATIONS'), + icon: 'i-lucide-message-circle', + children: [ + { + name: 'All', + label: t('SIDEBAR.ALL_CONVERSATIONS'), + activeOn: ['inbox_conversation'], + to: accountScopedRoute('home'), + }, + { + name: 'Mentions', + label: t('SIDEBAR.MENTIONED_CONVERSATIONS'), + activeOn: ['conversation_through_mentions'], + to: accountScopedRoute('conversation_mentions'), + }, + { + name: 'Participating', + label: t('SIDEBAR.PARTICIPATING_CONVERSATIONS'), + activeOn: ['conversation_through_participating'], + to: accountScopedRoute('conversation_participating'), + }, + { + name: 'Unattended', + activeOn: ['conversation_through_unattended'], + label: t('SIDEBAR.UNATTENDED_CONVERSATIONS'), + to: accountScopedRoute('conversation_unattended'), + }, + { + name: 'Folders', + label: t('SIDEBAR.CUSTOM_VIEWS_FOLDER'), + icon: 'i-lucide-folder', + activeOn: ['conversations_through_folders'], + children: conversationCustomViews.value.map(view => ({ + name: `${view.name}-${view.id}`, + label: view.name, + to: accountScopedRoute('folder_conversations', { + id: view.id, + }), + })), + }, + { + name: 'Teams', + label: t('SIDEBAR.TEAMS'), + icon: 'i-lucide-users', + activeOn: ['conversations_through_team'], + children: sortedTeams.value.map(team => ({ + name: `${team.name}-${team.id}`, + label: team.name, + badgeCount: getTeamUnreadCount.value(team.id), + to: accountScopedRoute('team_conversations', { + teamId: team.id, + }), + })), + }, + { + name: 'Channels', + label: t('SIDEBAR.CHANNELS'), + icon: 'i-lucide-mailbox', + activeOn: ['conversation_through_inbox'], + children: sortedInboxes.value.map(inbox => ({ + name: `${inbox.name}-${inbox.id}`, + label: inbox.name, + badgeCount: getInboxUnreadCount.value(inbox.id), + icon: h(ChannelIcon, { inbox, class: 'size-[16px]' }), + to: accountScopedRoute('inbox_dashboard', { + inbox_id: inbox.id, + }), + component: leafProps => + h(ChannelLeaf, { + label: leafProps.label, + active: leafProps.active, + inbox, + badgeCount: leafProps.badgeCount, + }), + })), + }, + { + name: 'Labels', + label: t('SIDEBAR.LABELS'), + icon: 'i-lucide-tag', + activeOn: ['conversations_through_label'], + children: sortedLabels.value.map(label => ({ + name: `${label.title}-${label.id}`, + label: label.title, + badgeCount: getLabelUnreadCount.value(label.id), + icon: h('span', { + class: `size-[8px] rounded-sm`, + style: { backgroundColor: label.color }, + }), + to: accountScopedRoute('label_conversations', { + label: label.title, + }), + })), + }, + ], + }, + { + name: 'Captain', + icon: 'i-woot-captain', + label: t('SIDEBAR.CAPTAIN'), + activeOn: ['captain_assistants_create_index'], + children: [ + { + name: 'FAQs', + label: t('SIDEBAR.CAPTAIN_RESPONSES'), + activeOn: [ + 'captain_assistants_responses_index', + 'captain_assistants_responses_pending', + ], + to: accountScopedRoute('captain_assistants_index', { + navigationPath: 'captain_assistants_responses_index', + }), + }, + { + name: 'Documents', + label: t('SIDEBAR.CAPTAIN_DOCUMENTS'), + activeOn: ['captain_assistants_documents_index'], + to: accountScopedRoute('captain_assistants_index', { + navigationPath: 'captain_assistants_documents_index', + }), + }, + { + name: 'Scenarios', + label: t('SIDEBAR.CAPTAIN_SCENARIOS'), + activeOn: ['captain_assistants_scenarios_index'], + to: accountScopedRoute('captain_assistants_index', { + navigationPath: 'captain_assistants_scenarios_index', + }), + }, + { + name: 'Playground', + label: t('SIDEBAR.CAPTAIN_PLAYGROUND'), + activeOn: ['captain_assistants_playground_index'], + to: accountScopedRoute('captain_assistants_index', { + navigationPath: 'captain_assistants_playground_index', + }), + }, + { + name: 'Inboxes', + label: t('SIDEBAR.CAPTAIN_INBOXES'), + activeOn: ['captain_assistants_inboxes_index'], + to: accountScopedRoute('captain_assistants_index', { + navigationPath: 'captain_assistants_inboxes_index', + }), + }, + { + name: 'Tools', + label: t('SIDEBAR.CAPTAIN_TOOLS'), + activeOn: ['captain_tools_index'], + to: accountScopedRoute('captain_assistants_index', { + navigationPath: 'captain_tools_index', + }), + }, + { + name: 'Settings', + label: t('SIDEBAR.CAPTAIN_SETTINGS'), + activeOn: [ + 'captain_assistants_settings_index', + 'captain_assistants_guidelines_index', + 'captain_assistants_guardrails_index', + ], + to: accountScopedRoute('captain_assistants_index', { + navigationPath: 'captain_assistants_settings_index', + }), + }, + ], + }, + { + name: 'Contacts', + label: t('SIDEBAR.CONTACTS'), + icon: 'i-lucide-contact', + children: [ + { + name: 'All Contacts', + label: t('SIDEBAR.ALL_CONTACTS'), + to: accountScopedRoute( + 'contacts_dashboard_index', + {}, + { page: 1, search: undefined } + ), + activeOn: ['contacts_dashboard_index', 'contacts_edit'], + }, + { + name: 'Active', + label: t('SIDEBAR.ACTIVE'), + to: accountScopedRoute('contacts_dashboard_active'), + activeOn: ['contacts_dashboard_active'], + }, + { + name: 'Segments', + icon: 'i-lucide-group', + label: t('SIDEBAR.CUSTOM_VIEWS_SEGMENTS'), + children: contactCustomViews.value.map(view => ({ + name: `${view.name}-${view.id}`, + label: view.name, + to: accountScopedRoute( + 'contacts_dashboard_segments_index', + { segmentId: view.id }, + { page: 1 } + ), + activeOn: [ + 'contacts_dashboard_segments_index', + 'contacts_edit_segment', + ], + })), + }, + { + name: 'Tagged With', + icon: 'i-lucide-tag', + label: t('SIDEBAR.TAGGED_WITH'), + children: labels.value.map(label => ({ + name: `${label.title}-${label.id}`, + label: label.title, + icon: h('span', { + class: `size-[8px] rounded-sm`, + style: { backgroundColor: label.color }, + }), + to: accountScopedRoute( + 'contacts_dashboard_labels_index', + { label: label.title }, + { page: 1, search: undefined } + ), + activeOn: [ + 'contacts_dashboard_labels_index', + 'contacts_edit_label', + ], + })), + }, + ], + }, + { + name: 'Companies', + label: t('SIDEBAR.COMPANIES'), + icon: 'i-lucide-building-2', + children: [ + { + name: 'All Companies', + label: t('SIDEBAR.ALL_COMPANIES'), + to: accountScopedRoute( + 'companies_dashboard_index', + {}, + { page: 1, search: undefined } + ), + activeOn: [ + 'companies_dashboard_index', + 'companies_dashboard_show', + ], + }, + ], + }, + { + name: 'Reports', + label: t('SIDEBAR.REPORTS'), + icon: 'i-lucide-chart-spline', + children: [ + { + name: 'Report Overview', + label: t('SIDEBAR.REPORTS_OVERVIEW'), + to: accountScopedRoute('account_overview_reports'), + }, + { + name: 'Report Conversation', + label: t('SIDEBAR.REPORTS_CONVERSATION'), + to: accountScopedRoute('conversation_reports'), + }, + ...reportRoutes.value, + { + name: 'Reports CSAT', + label: t('SIDEBAR.CSAT'), + to: accountScopedRoute('csat_reports'), + }, + { + name: 'Reports SLA', + label: t('SIDEBAR.REPORTS_SLA'), + to: accountScopedRoute('sla_reports'), + }, + { + name: 'Reports Bot', + label: t('SIDEBAR.REPORTS_BOT'), + to: accountScopedRoute('bot_reports'), + }, + ], + }, + { + name: 'Campaigns', + label: t('SIDEBAR.CAMPAIGNS'), + icon: 'i-lucide-megaphone', + children: [ + { + name: 'Live chat', + label: t('SIDEBAR.LIVE_CHAT'), + to: accountScopedRoute('campaigns_livechat_index'), + }, + { + name: 'SMS', + label: t('SIDEBAR.SMS'), + to: accountScopedRoute('campaigns_sms_index'), + }, + { + name: 'WhatsApp', + label: t('SIDEBAR.WHATSAPP'), + to: accountScopedRoute('campaigns_whatsapp_index'), + }, + ], + }, + { + name: 'Portals', + label: t('SIDEBAR.HELP_CENTER.TITLE'), + icon: 'i-lucide-library-big', + children: [ + { + name: 'Articles', + label: t('SIDEBAR.HELP_CENTER.ARTICLES'), + activeOn: [ + 'portals_articles_index', + 'portals_articles_new', + 'portals_articles_edit', + ], + to: accountScopedRoute('portals_index', { + navigationPath: 'portals_articles_index', + }), + }, + { + name: 'Categories', + label: t('SIDEBAR.HELP_CENTER.CATEGORIES'), + activeOn: [ + 'portals_categories_index', + 'portals_categories_articles_index', + 'portals_categories_articles_edit', + ], + to: accountScopedRoute('portals_index', { + navigationPath: 'portals_categories_index', + }), + }, + { + name: 'Locales', + label: t('SIDEBAR.HELP_CENTER.LOCALES'), + activeOn: ['portals_locales_index'], + to: accountScopedRoute('portals_index', { + navigationPath: 'portals_locales_index', + }), + }, + { + name: 'Settings', + label: t('SIDEBAR.HELP_CENTER.SETTINGS'), + activeOn: ['portals_settings_index'], + to: accountScopedRoute('portals_index', { + navigationPath: 'portals_settings_index', + }), + }, + ], + }, + { + name: 'Settings', + label: t('SIDEBAR.SETTINGS'), + icon: 'i-lucide-bolt', + children: [ + { + name: 'Settings Account Settings', + label: t('SIDEBAR.ACCOUNT_SETTINGS'), + icon: 'i-lucide-briefcase', + to: accountScopedRoute('general_settings_index'), + }, + { + name: 'Settings Copilot', + label: t('SIDEBAR.CAPTAIN_AI'), + icon: 'i-woot-captain', + to: accountScopedRoute('copilot_settings_index'), + }, + { + name: 'Settings Agents', + label: t('SIDEBAR.AGENTS'), + icon: 'i-lucide-square-user', + to: accountScopedRoute('agent_list'), + }, + { + name: 'Settings Teams', + label: t('SIDEBAR.TEAMS'), + icon: 'i-lucide-users', + activeOn: [ + 'settings_teams_list', + 'settings_teams_new', + 'settings_teams_finish', + 'settings_teams_add_agents', + 'settings_teams_show', + 'settings_teams_edit', + 'settings_teams_edit_members', + 'settings_teams_edit_finish', + ], + to: accountScopedRoute('settings_teams_list'), + }, + ...(hasAdvancedAssignment.value + ? [ + { + name: 'Settings Agent Assignment', + label: t('SIDEBAR.AGENT_ASSIGNMENT'), + icon: 'i-lucide-user-cog', + activeOn: [ + 'assignment_policy_index', + 'agent_assignment_policy_index', + 'agent_assignment_policy_create', + 'agent_assignment_policy_edit', + 'agent_capacity_policy_index', + 'agent_capacity_policy_create', + 'agent_capacity_policy_edit', + ], + to: accountScopedRoute( + 'assignment_policy_index' + ), + }, + ] + : []), + { + name: 'Settings Inboxes', + label: t('SIDEBAR.INBOXES'), + icon: 'i-lucide-inbox', + activeOn: [ + 'settings_inbox_list', + 'settings_inbox_show', + 'settings_inbox_new', + 'settings_inbox_finish', + 'settings_inboxes_page_channel', + 'settings_inboxes_add_agents', + ], + to: accountScopedRoute('settings_inbox_list'), + }, + { + name: 'Settings Labels', + label: t('SIDEBAR.LABELS'), + icon: 'i-lucide-tags', + to: accountScopedRoute('labels_list'), + }, + { + name: 'Settings Custom Attributes', + label: t('SIDEBAR.CUSTOM_ATTRIBUTES'), + icon: 'i-lucide-code', + to: accountScopedRoute('attributes_list'), + }, + { + name: 'Settings Automation', + label: t('SIDEBAR.AUTOMATION'), + icon: 'i-lucide-repeat', + to: accountScopedRoute('automation_list'), + }, + { + name: 'Settings Agent Bots', + label: t('SIDEBAR.AGENT_BOTS'), + icon: 'i-lucide-bot', + to: accountScopedRoute('agent_bots'), + }, + { + name: 'Settings Macros', + label: t('SIDEBAR.MACROS'), + icon: 'i-lucide-toy-brick', + to: accountScopedRoute('macros_wrapper'), + }, + { + name: 'Settings Canned Responses', + label: t('SIDEBAR.CANNED_RESPONSES'), + icon: 'i-lucide-message-square-quote', + to: accountScopedRoute('canned_list'), + }, + { + name: 'Settings Integrations', + label: t('SIDEBAR.INTEGRATIONS'), + icon: 'i-lucide-blocks', + to: accountScopedRoute('settings_applications'), + }, + { + name: 'Settings Audit Logs', + label: t('SIDEBAR.AUDIT_LOGS'), + icon: 'i-lucide-briefcase', + to: accountScopedRoute('auditlogs_list'), + }, + { + name: 'Settings Custom Roles', + label: t('SIDEBAR.CUSTOM_ROLES'), + icon: 'i-lucide-shield-plus', + to: accountScopedRoute('custom_roles_list'), + }, + { + name: 'Settings Sla', + label: t('SIDEBAR.SLA'), + icon: 'i-lucide-clock-alert', + to: accountScopedRoute('sla_list'), + }, + { + name: 'Conversation Workflow', + label: t('SIDEBAR.CONVERSATION_WORKFLOW'), + icon: 'i-lucide-workflow', + to: accountScopedRoute('conversation_workflow_index'), + }, + { + name: 'Settings Security', + label: t('SIDEBAR.SECURITY'), + icon: 'i-lucide-shield', + to: accountScopedRoute('security_settings_index'), + }, + { + name: 'Settings Billing', + label: t('SIDEBAR.BILLING'), + icon: 'i-lucide-credit-card', + to: accountScopedRoute('billing_settings_index'), + }, + ], + }, + ]; }); diff --git a/frontend/app/javascript/dashboard/components/ChatList.vue b/frontend/app/javascript/dashboard/components/ChatList.vue index 26f2bfde..3ba3435a 100644 --- a/frontend/app/javascript/dashboard/components/ChatList.vue +++ b/frontend/app/javascript/dashboard/components/ChatList.vue @@ -549,9 +549,6 @@ function onToggleAdvanceFiltersModal() { } function fetchConversations() { - if (chatListLoading.value) { - return; - } store.dispatch('updateChatListFilters', conversationFilters.value); store.dispatch('fetchAllConversations').then(emitConversationLoaded); } diff --git a/frontend/app/javascript/dashboard/store/modules/conversations/getters.js b/frontend/app/javascript/dashboard/store/modules/conversations/getters.js index 60aa6e75..745c3acc 100644 --- a/frontend/app/javascript/dashboard/store/modules/conversations/getters.js +++ b/frontend/app/javascript/dashboard/store/modules/conversations/getters.js @@ -78,7 +78,7 @@ const getters = { const currentUserID = rootGetters.getCurrentUser?.id; return _state.allConversations.filter(conversation => { - const { assignee } = conversation.meta; + const { assignee } = conversation.meta || {}; const isAssignedToMe = assignee && assignee.id === currentUserID; const shouldFilter = applyPageFilters(conversation, activeFilters); const isChatMine = isAssignedToMe && shouldFilter; diff --git a/frontend/app/javascript/dashboard/store/modules/conversations/helpers/actionHelpers.js b/frontend/app/javascript/dashboard/store/modules/conversations/helpers/actionHelpers.js index 8c5575c3..5ac2a795 100644 --- a/frontend/app/javascript/dashboard/store/modules/conversations/helpers/actionHelpers.js +++ b/frontend/app/javascript/dashboard/store/modules/conversations/helpers/actionHelpers.js @@ -59,7 +59,6 @@ export const buildConversationList = ( 'conversationLabels/setBulkConversationLabels', conversationList ); - context.commit(types.CLEAR_LIST_LOADING_STATUS); setContacts(context.commit, conversationList); setPageFilter({ dispatch: context.dispatch, diff --git a/frontend/app/javascript/dashboard/store/modules/conversations/index.js b/frontend/app/javascript/dashboard/store/modules/conversations/index.js index 2dfe0f13..df052533 100644 --- a/frontend/app/javascript/dashboard/store/modules/conversations/index.js +++ b/frontend/app/javascript/dashboard/store/modules/conversations/index.js @@ -242,6 +242,8 @@ export const mutations = { }, [types.UPDATE_CONVERSATION](_state, conversation) { + if (!conversation?.id) return; + const { allConversations } = _state; const index = allConversations.findIndex(c => c.id === conversation.id); diff --git a/frontend/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js b/frontend/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js index 77867bb0..d1fc6480 100644 --- a/frontend/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js +++ b/frontend/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js @@ -758,6 +758,22 @@ describe('#mutations', () => { }); describe('#UPDATE_CONVERSATION', () => { + it('should ignore malformed realtime payloads without an id', () => { + const state = { + allConversations: [{ id: 1, status: 'open', meta: {} }], + conversationFilters: {}, + }; + + mutations[types.UPDATE_CONVERSATION](state, { + conversation: { id: 1 }, + message: { id: 2 }, + }); + + expect(state.allConversations).toEqual([ + { id: 1, status: 'open', meta: {} }, + ]); + }); + it('should update existing conversation', () => { const state = { allConversations: [ diff --git a/package.json b/package.json index 686e3072..9cb0e433 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "dev": "concurrently -n backend,frontend -c blue,green \"pnpm dev:backend\" \"pnpm dev:frontend\"", "fake:start": "cd channels/fake && tsx src/index.ts", "fake:dev": "cd channels/fake && tsx watch src/index.ts", - "fake:test": "cd channels/fake && vitest run", + "fake:test": "pnpm --dir channels/fake test", "dev:all": "concurrently -n backend,frontend,fake -c blue,green,magenta \"pnpm dev:backend\" \"pnpm dev:frontend\" \"pnpm fake:start\"", "build:frontend": "cd frontend && pnpm build", "build:sdk": "cd frontend && pnpm build:sdk",