From 5f0fed695fa4f13dd11d18f0e91a8c81990e9567 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Tue, 19 May 2026 11:03:48 +0530 Subject: [PATCH 01/97] fix: make CSAT template insert idempotent in v2.0.0 migration --- internal/migrations/v2.0.0.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/migrations/v2.0.0.go b/internal/migrations/v2.0.0.go index 5213002c..14a8c709 100644 --- a/internal/migrations/v2.0.0.go +++ b/internal/migrations/v2.0.0.go @@ -205,7 +205,7 @@ func V2_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error { // Add built-in CSAT request email template. _, err = db.Exec(` INSERT INTO templates ("type", body, is_default, "name", subject, is_builtin) - VALUES ( + SELECT 'email_notification'::template_type, '

@@ -255,7 +255,9 @@ func V2_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error { 'CSAT request', '', true - ) ON CONFLICT DO NOTHING; + WHERE NOT EXISTS ( + SELECT 1 FROM templates WHERE name = 'CSAT request' AND is_builtin = true + ); `) if err != nil { return err From df1d5815ce9ae25d9c8e7efc04d4747c00071e58 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Tue, 19 May 2026 11:26:15 +0530 Subject: [PATCH 02/97] fix: show "1 selected" in conversation bulk action toolbar --- i18n/en-US.json | 2 +- i18n/fr-FR.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/i18n/en-US.json b/i18n/en-US.json index 6dd81e2c..38713191 100644 --- a/i18n/en-US.json +++ b/i18n/en-US.json @@ -477,7 +477,7 @@ "conversation.bulkActions.failedToast": "Some conversations couldn't be updated", "conversation.bulkActions.selectAll": "Select all conversations", "conversation.bulkActions.selectConversation": "Select conversation", - "conversation.bulkActions.selected": "No conversations selected | {count} selected", + "conversation.bulkActions.selected": "No conversations selected | 1 selected | {count} selected", "conversation.bulkActions.successToast": "Conversations updated", "conversation.bulkActions.toolbar": "Bulk actions toolbar", "conversation.couldNotFetch": "Could not fetch conversations", diff --git a/i18n/fr-FR.json b/i18n/fr-FR.json index 093d5cd2..1bc9a2c6 100644 --- a/i18n/fr-FR.json +++ b/i18n/fr-FR.json @@ -478,7 +478,7 @@ "conversation.bulkActions.failedToast": "Certaines conversations n'ont pas pu être modifiées", "conversation.bulkActions.selectAll": "Sélectionner toutes les conversations", "conversation.bulkActions.selectConversation": "Sélectionner la conversation", - "conversation.bulkActions.selected": "Aucune conversation sélectionnée | {count} sélectionnée(s)", + "conversation.bulkActions.selected": "Aucune conversation sélectionnée | 1 sélectionnée | {count} sélectionnées", "conversation.bulkActions.successToast": "Conversations modifiées", "conversation.bulkActions.toolbar": "Barre d'outils d'actions en masse", "conversation.couldNotFetch": "Impossible d'obtenir des conversations", @@ -1079,4 +1079,4 @@ "widget.conversationClosed": "Cette conversation a été fermée", "widget.ipBlocked": "Accès refusé", "widget.prechatForm.startChat": "Démarrer le chat" -} +} From 4e13e72cc395e18cbbc73dcd5b41a1b2d98424cb Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Tue, 19 May 2026 14:13:56 +0530 Subject: [PATCH 03/97] fix: harden media + conversation queries against empty-uuid casts --- cmd/templates.go | 6 ++++++ internal/conversation/conversation.go | 2 +- internal/conversation/message.go | 2 +- internal/conversation/queries.sql | 8 ++++---- internal/media/media.go | 9 ++++++--- internal/media/queries.sql | 11 ++++++++--- 6 files changed, 26 insertions(+), 12 deletions(-) diff --git a/cmd/templates.go b/cmd/templates.go index 52e9cf0a..c8eda7d4 100644 --- a/cmd/templates.go +++ b/cmd/templates.go @@ -53,6 +53,9 @@ func handleCreateTemplate(r *fastglue.Request) error { if req.Name == "" { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError) } + if req.Type == "" { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`type`"), nil, envelope.InputError) + } template, err := app.tmpl.Create(req) if err != nil { return sendErrorEnvelope(r, err) @@ -77,6 +80,9 @@ func handleUpdateTemplate(r *fastglue.Request) error { if req.Name == "" { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError) } + if req.Type == "" { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`type`"), nil, envelope.InputError) + } updatedTemplate, err := app.tmpl.Update(id, req) if err != nil { return sendErrorEnvelope(r, err) diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index cfcf9303..056eb3f2 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -141,7 +141,7 @@ type mediaStore interface { SetContentID(id int, contentID string) error GetByModel(id int, model string) ([]mmodels.Media, error) GetByContentIDs(contentIDs []string, conversationUUID string) ([]mmodels.Media, error) - ContentIDExists(contentID string) (bool, string, error) + ContentIDExists(contentID, conversationUUID string) (bool, string, error) Upload(fileName, contentType string, content io.ReadSeeker) (string, string, error) UploadAndInsert(fileName, contentType, contentID string, modelType null.String, modelID null.Int, content io.ReadSeeker, fileSize int, disposition null.String, meta []byte) (mmodels.Media, error) } diff --git a/internal/conversation/message.go b/internal/conversation/message.go index 3fa7c875..822a496c 100644 --- a/internal/conversation/message.go +++ b/internal/conversation/message.go @@ -1425,7 +1425,7 @@ func (m *Manager) findExistingMedia(rawContentID, conversationUUID string) (stri if !strings.HasPrefix(rawContentID, "ldsk-") { storedCID = conversationUUID + "_" + rawContentID } - exists, mediaUUID, err := m.mediaStore.ContentIDExists(storedCID) + exists, mediaUUID, err := m.mediaStore.ContentIDExists(storedCID, conversationUUID) if err != nil { m.lo.Error("error checking media existence by content ID", "content_id", storedCID, "error", err) } diff --git a/internal/conversation/queries.sql b/internal/conversation/queries.sql index 72f08d9d..6b1434fb 100644 --- a/internal/conversation/queries.sql +++ b/internal/conversation/queries.sql @@ -204,10 +204,10 @@ LEFT JOIN LATERAL ( ORDER BY se.created_at DESC LIMIT 1 ) nxt_resp_event ON true -WHERE +WHERE ($1 > 0 AND c.id = $1) - OR - ($2::uuid IS NOT NULL AND c.uuid = $2::uuid) + OR + (NULLIF($2, '')::uuid IS NOT NULL AND c.uuid = NULLIF($2, '')::uuid) OR ($3::TEXT != '' AND c.reference_number = $3::TEXT) @@ -799,7 +799,7 @@ ORDER BY cd.updated_at DESC; DELETE FROM conversation_drafts WHERE conversation_id IN ( SELECT id FROM conversations - WHERE ($1 > 0 AND id = $1) OR ($2::uuid IS NOT NULL AND uuid = $2::uuid) + WHERE ($1 > 0 AND id = $1) OR (NULLIF($2, '')::uuid IS NOT NULL AND uuid = NULLIF($2, '')::uuid) ) AND user_id = $3; -- name: delete-stale-drafts diff --git a/internal/media/media.go b/internal/media/media.go index baaf22e5..84719e52 100644 --- a/internal/media/media.go +++ b/internal/media/media.go @@ -180,10 +180,13 @@ func (m *Manager) SetContentID(id int, contentID string) error { return nil } -// ContentIDExists checks if a content_id exists in the database and returns the UUID of the media file. -func (m *Manager) ContentIDExists(contentID string) (bool, string, error) { +// ContentIDExists reports whether a media row with the given content_id is linked to a message in the given conversation. Scoped this way so an orphan media row (e.g., from a partial failure) doesn't short-circuit a retry into skipping the upload. +func (m *Manager) ContentIDExists(contentID, conversationUUID string) (bool, string, error) { + if contentID == "" || conversationUUID == "" { + return false, "", nil + } var uuid string - if err := m.queries.ContentIDExists.Get(&uuid, contentID); err != nil { + if err := m.queries.ContentIDExists.Get(&uuid, contentID, conversationUUID); err != nil { if errors.Is(err, sql.ErrNoRows) { return false, "", nil } diff --git a/internal/media/queries.sql b/internal/media/queries.sql index 78fdfbf8..bbebc1d8 100644 --- a/internal/media/queries.sql +++ b/internal/media/queries.sql @@ -17,10 +17,10 @@ RETURNING id; -- name: get-media SELECT id, created_at, updated_at, "uuid", store, filename, content_type, content_id, model_id, model_type, disposition, "size", meta FROM media -WHERE +WHERE ($1 > 0 AND id = $1) OR - ($2 != '' AND uuid = $2::uuid) + ($2 != '' AND uuid = NULLIF($2, '')::uuid) -- name: get-media-by-uuid SELECT id, created_at, updated_at, "uuid", store, filename, content_type, content_id, model_id, model_type, disposition, "size", meta @@ -51,7 +51,12 @@ WHERE model_type = 'messages' AND created_at < NOW() - INTERVAL '7 days'; -- name: content-id-exists -SELECT uuid FROM media WHERE content_id = $1; +SELECT m.uuid +FROM media m +INNER JOIN conversation_messages cm ON cm.id = m.model_id +WHERE m.model_type = 'messages' + AND m.content_id = $1 + AND cm.conversation_id = (SELECT id FROM conversations WHERE uuid = $2::uuid LIMIT 1); -- name: get-media-by-content-ids SELECT m.id, m.created_at, m.updated_at, m."uuid", m.store, m.filename, m.content_type, m.content_id, m.model_id, m.model_type, m.disposition, m."size", m.meta From 1607997df3cd22c68903b12ae23cd3fcc7831463 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Tue, 19 May 2026 14:19:44 +0530 Subject: [PATCH 04/97] feat: show inbox name in conversation sidebar --- .../conversation/sidebar/ConversationInfo.vue | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/frontend/apps/main/src/features/conversation/sidebar/ConversationInfo.vue b/frontend/apps/main/src/features/conversation/sidebar/ConversationInfo.vue index a43943aa..1d86c700 100644 --- a/frontend/apps/main/src/features/conversation/sidebar/ConversationInfo.vue +++ b/frontend/apps/main/src/features/conversation/sidebar/ConversationInfo.vue @@ -1,5 +1,18 @@ + + diff --git a/frontend/apps/main/src/features/conversation/sidebar/ConversationInfo.vue b/frontend/apps/main/src/features/conversation/sidebar/ConversationInfo.vue index 1d86c700..6095cdf0 100644 --- a/frontend/apps/main/src/features/conversation/sidebar/ConversationInfo.vue +++ b/frontend/apps/main/src/features/conversation/sidebar/ConversationInfo.vue @@ -1,10 +1,8 @@ + + diff --git a/frontend/apps/main/src/websocket.js b/frontend/apps/main/src/websocket.js index a81aa94b..2c234f41 100644 --- a/frontend/apps/main/src/websocket.js +++ b/frontend/apps/main/src/websocket.js @@ -1,5 +1,6 @@ import { useConversationStore } from './stores/conversation' import { useNotificationStore } from './stores/notification' +import { useUsersStore } from './stores/users' import { WS_EVENT, WS_EPHEMERAL_TYPES } from './constants/websocket' import { playNotificationSound } from '@shared-ui/composables/useNotificationSound' @@ -17,6 +18,7 @@ export class WebSocketClient { this.lastPong = Date.now() this.convStore = useConversationStore() this.notificationStore = useNotificationStore() + this.usersStore = useUsersStore() this.messageQueue = [] this.maxQueueSize = 50 this.queueTimeoutMs = 30000 @@ -71,8 +73,19 @@ export class WebSocketClient { const uuid = data.data.conversation_uuid const isOpen = this.convStore.conversation.data?.uuid === uuid const isFromContact = data.data.sender_type === 'contact' + const convPayload = data.data.conversation + + if (convPayload) { + this.convStore.handleConvPush(convPayload) + } else { + this.convStore.mergeConversationUpdate({ + uuid, + last_message: data.data.preview, + last_message_at: data.data.created_at, + last_message_sender: data.data.sender_type, + }) + } - // Defer the sound if the conversation isn't visible yet so it plays once it joins the list. if (isFromContact && document.hidden) { if (isOpen || this.convStore.isConversationInList(uuid)) { playNotificationSound() @@ -81,20 +94,19 @@ export class WebSocketClient { } } - // Not open conversation but in the list, increment unread count. - if (isFromContact && !isOpen && this.convStore.isConversationInList(uuid)) { + if (!isOpen && this.convStore.isConversationInList(uuid)) { this.convStore.incrementUnread(uuid) } - this.convStore.mergeConversationUpdate({ - uuid, - last_message: data.data.preview, - last_message_at: data.data.created_at, - last_message_sender: data.data.sender_type, - }) this.convStore.updateConversationMessage(data.data) }, - [WS_EVENT.NEW_CONVERSATION]: () => this.convStore.refreshConversationList(), + [WS_EVENT.NEW_CONVERSATION]: () => { + if (data.data && data.data.uuid) { + this.convStore.handleConvPush(data.data) + } else { + this.convStore.refreshConversationList() + } + }, // Property updates for conversation and message. [WS_EVENT.MESSAGE_UPDATE]: () => this.convStore.mergeMessageUpdate(data.data), [WS_EVENT.CONVERSATION_UPDATE]: () => this.convStore.mergeConversationUpdate(data.data), @@ -103,7 +115,9 @@ export class WebSocketClient { this.convStore.updateTypingStatus(data.data) }, // New notification. - [WS_EVENT.NEW_NOTIFICATION]: () => this.notificationStore.addNotification(data.data) + [WS_EVENT.NEW_NOTIFICATION]: () => this.notificationStore.addNotification(data.data), + [WS_EVENT.AGENT_AVAILABILITY_UPDATE]: () => + this.usersStore.setAvailability(data.data.agent_id, data.data.availability_status), } const handler = handlers[data.type] diff --git a/frontend/shared-ui/assets/styles/main.scss b/frontend/shared-ui/assets/styles/main.scss index fc4f439a..a000f464 100644 --- a/frontend/shared-ui/assets/styles/main.scss +++ b/frontend/shared-ui/assets/styles/main.scss @@ -6,6 +6,19 @@ font-size: 16px; } +::view-transition-old(root), +::view-transition-new(root) { + animation-duration: 0.15s; + animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); +} + +@media (prefers-reduced-motion: reduce) { + ::view-transition-old(root), + ::view-transition-new(root) { + animation: none; + } +} + @layer base { html, body { diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index cb7d8994..6788217b 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -261,6 +261,7 @@ type queries struct { // Conversation queries. GetConversationUUID *sqlx.Stmt `query:"get-conversation-uuid"` GetConversation *sqlx.Stmt `query:"get-conversation"` + GetConversationListItem *sqlx.Stmt `query:"get-conversation-list-item"` GetConversationsCreatedAfter *sqlx.Stmt `query:"get-conversations-created-after"` GetUnassignedConversations *sqlx.Stmt `query:"get-unassigned-conversations"` GetConversations string `query:"get-conversations"` @@ -367,7 +368,11 @@ func (c *Manager) CreateConversation(contactID, inboxID int, lastMessage string, c.lo.Error("error inserting new conversation into the DB", "error", err) return 0, "", err } - c.BroadcastNewConversation() + if item, err := c.GetConversationListItem(uuid); err == nil { + c.BroadcastNewConversation(&item) + } else { + c.lo.Error("error fetching conversation list item for broadcast", "uuid", uuid, "error", err) + } return id, uuid, nil } @@ -778,16 +783,13 @@ func (c *Manager) UpdateConversationTeamAssignee(uuid string, teamID int, actor // UpdateAssignee updates the assignee of a conversation. func (c *Manager) UpdateAssignee(uuid string, assigneeID int, assigneeType string) error { - var prop string switch assigneeType { case models.AssigneeTypeUser: - prop = "assigned_user_id" if _, err := c.q.UpdateConversationAssignedUser.Exec(uuid, assigneeID); err != nil { c.lo.Error("error updating conversation assignee", "error", err) return fmt.Errorf("updating assignee: %w", err) } case models.AssigneeTypeTeam: - prop = "assigned_team_id" if _, err := c.q.UpdateConversationAssignedTeam.Exec(uuid, assigneeID); err != nil { c.lo.Error("error updating conversation assignee", "error", err) return fmt.Errorf("updating assignee: %w", err) @@ -795,8 +797,11 @@ func (c *Manager) UpdateAssignee(uuid string, assigneeID int, assigneeType strin default: return fmt.Errorf("invalid assignee type: %s", assigneeType) } - // Broadcast update to all subscribers. - c.BroadcastConversationUpdate(uuid, map[string]any{prop: assigneeID}) + if item, err := c.GetConversationListItem(uuid); err == nil { + c.BroadcastNewConversation(&item) + } else { + c.lo.Error("error fetching conversation list item for assignee broadcast", "uuid", uuid, "error", err) + } return nil } @@ -899,7 +904,6 @@ func (c *Manager) UpdateConversationStatus(uuid string, statusID int, status, sn return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil) } - // Broadcast updates using websocket. agentData := map[string]any{"status": status} if oldStatus != models.StatusResolved && status == models.StatusResolved { resolvedAt := conversationBeforeChange.ResolvedAt.Time @@ -908,6 +912,18 @@ func (c *Manager) UpdateConversationStatus(uuid string, statusID int, status, sn } agentData["resolved_at"] = resolvedAt.Format(time.RFC3339) } + if status == models.StatusClosed { + closedAt := time.Now() + if conversation.ID != 0 && !conversation.ClosedAt.Time.IsZero() { + closedAt = conversation.ClosedAt.Time + } + agentData["closed_at"] = closedAt.Format(time.RFC3339) + } + if status == models.StatusSnoozed { + agentData["snoozed_until"] = snoozeUntil.Format(time.RFC3339) + } else if oldStatus == models.StatusSnoozed { + agentData["snoozed_until"] = nil + } c.BroadcastConversationUpdate(uuid, agentData) // Evaluate automation rules. @@ -1004,6 +1020,8 @@ func (c *Manager) SetConversationTags(uuid string, action string, tagNames []str } } + c.BroadcastConversationUpdate(uuid, map[string]any{"tags": newTags}) + return nil } @@ -1231,7 +1249,15 @@ func (m *Manager) ApplySLA(conversation models.Conversation, policyID int, actor return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil) } - // Record the SLA application as an activity. + if updated, ferr := m.GetConversation(0, conversation.UUID, ""); ferr == nil { + m.BroadcastConversationUpdate(conversation.UUID, map[string]any{ + "sla_policy_id": updated.SLAPolicyID.Int, + "applied_sla_id": updated.AppliedSLAID.Int, + "first_response_deadline_at": nullTimeOrNil(updated.FirstResponseDueAt), + "resolution_deadline_at": nullTimeOrNil(updated.ResolutionDueAt), + }) + } + return m.RecordSLASet(conversation.UUID, policy.Name, actor) } @@ -1352,12 +1378,10 @@ func (m *Manager) RemoveConversationAssignee(uuid, typ string, actor umodels.Use } } - // Broadcast ws update. - switch typ { - case models.AssigneeTypeUser: - m.BroadcastConversationUpdate(uuid, map[string]any{"assigned_user_id": nil}) - case models.AssigneeTypeTeam: - m.BroadcastConversationUpdate(uuid, map[string]any{"assigned_team_id": nil}) + if item, err := m.GetConversationListItem(uuid); err == nil { + m.BroadcastNewConversation(&item) + } else { + m.lo.Error("error fetching conversation list item for unassign broadcast", "uuid", uuid, "error", err) } return nil @@ -1842,6 +1866,59 @@ func (c *Manager) formatRefMarker(ref string) string { return strings.ReplaceAll(c.subjectRefFormat, "{ref}", ref) } +func (c *Manager) GetConversationListItem(uuid string) (models.ConversationListItem, error) { + var item models.ConversationListItem + if err := c.q.GetConversationListItem.Get(&item, uuid); err != nil { + return item, err + } + return item, nil +} + +func (c *Manager) AuthorizedConnectedAgentIDs(assignedUserID, assignedTeamID null.Int) []int { + connected := c.wsHub.ConnectedUserIDs() + if len(connected) == 0 { + return nil + } + out := make([]int, 0, len(connected)) + for _, id := range connected { + agent, err := c.userStore.GetAgentCachedOrLoad(id) + if err != nil { + continue + } + if !slices.Contains(agent.Permissions, authzmodels.PermConversationsRead) { + continue + } + if slices.Contains(agent.Permissions, authzmodels.PermConversationsReadAll) { + out = append(out, id) + continue + } + if slices.Contains(agent.Permissions, authzmodels.PermConversationsReadAssigned) && + assignedUserID.Valid && assignedUserID.Int == agent.ID { + out = append(out, id) + continue + } + if assignedTeamID.Valid { + tid := assignedTeamID.Int + if slices.Contains(agent.Permissions, authzmodels.PermConversationsReadTeamAll) && + slices.Contains(agent.Teams.IDs(), tid) { + out = append(out, id) + continue + } + if slices.Contains(agent.Permissions, authzmodels.PermConversationsReadTeamInbox) && + slices.Contains(agent.Teams.IDs(), tid) && !assignedUserID.Valid { + out = append(out, id) + continue + } + } + if slices.Contains(agent.Permissions, authzmodels.PermConversationsReadUnassigned) && + !assignedUserID.Valid && !assignedTeamID.Valid { + out = append(out, id) + continue + } + } + return out +} + // FilterAuthorizedListUUIDs returns the subset of UUIDs the agent can read, mirroring the conversation read-permission chain. func (c *Manager) FilterAuthorizedListUUIDs(agentID int, uuids []string) ([]string, error) { if len(uuids) == 0 { @@ -1869,3 +1946,10 @@ func (c *Manager) FilterAuthorizedListUUIDs(agentID int, uuids []string) ([]stri } return authorized, nil } + +func nullTimeOrNil(t null.Time) any { + if !t.Valid || t.Time.IsZero() { + return nil + } + return t.Time.Format(time.RFC3339) +} diff --git a/internal/conversation/drafts.go b/internal/conversation/drafts.go index 5e132a34..a62be6d3 100644 --- a/internal/conversation/drafts.go +++ b/internal/conversation/drafts.go @@ -3,31 +3,35 @@ package conversation import ( "context" "encoding/json" + "strings" "time" "github.com/abhinavxd/libredesk/internal/conversation/models" "github.com/abhinavxd/libredesk/internal/envelope" ) -// UpsertConversationDraft saves or updates a draft for a conversation. func (m *Manager) UpsertConversationDraft(conversationID, userID int, content string, meta json.RawMessage) (models.ConversationDraft, error) { var draft models.ConversationDraft + content = rewriteInlineImagesToCID(content) if err := m.q.UpsertConversationDraft.Get(&draft, conversationID, userID, content, meta); err != nil { m.lo.Error("error upserting conversation draft", "conversation_id", conversationID, "user_id", userID, "error", err) return draft, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil) } + draft.Content = m.resolveDraftInlineCIDs(draft.Content) return draft, nil } -// GetAllUserDrafts retrieves all drafts for a user. func (m *Manager) GetAllUserDrafts(userID int) ([]models.ConversationDraft, error) { var drafts = make([]models.ConversationDraft, 0) if err := m.q.GetAllUserDrafts.Select(&drafts, userID); err != nil { m.lo.Error("error fetching user drafts", "user_id", userID, "error", err) return nil, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil) } + for i := range drafts { + drafts[i].Content = m.resolveDraftInlineCIDs(drafts[i].Content) + } return drafts, nil } @@ -62,3 +66,19 @@ func (m *Manager) DeleteStaleDrafts(ctx context.Context, retentionPeriod time.Du return nil } + +func (m *Manager) resolveDraftInlineCIDs(content string) string { + cids := extractInlineContentIDs(content) + for _, cid := range cids { + uuid := strings.TrimPrefix(cid, "ldsk-") + if uuid == "" { + continue + } + media, err := m.mediaStore.Get(0, uuid) + if err != nil { + continue + } + content = strings.ReplaceAll(content, "cid:"+cid, m.mediaStore.GetURL(media.UUID, media.ContentType, media.Filename)) + } + return content +} diff --git a/internal/conversation/message.go b/internal/conversation/message.go index 59b9998c..2f1195fc 100644 --- a/internal/conversation/message.go +++ b/internal/conversation/message.go @@ -591,7 +591,13 @@ func (m *Manager) InsertMessage(message *models.Message) error { // Update conversation last message details (also conditionally updates last_interaction if not activity/private). m.UpdateConversationLastMessage(message.ConversationID, message.ConversationUUID, lastMessage, message.SenderType, message.Type, message.Private, message.CreatedAt, message.SenderID) - m.BroadcastNewMessage(message, lastMessage) + var convItem *models.ConversationListItem + if item, err := m.GetConversationListItem(message.ConversationUUID); err == nil { + convItem = &item + } else { + m.lo.Error("error fetching conversation list item for broadcast", "uuid", message.ConversationUUID, "error", err) + } + m.BroadcastNewMessage(message, convItem, lastMessage) } // Refetch the message to get all fields populated (e.g., author, media URLs). diff --git a/internal/conversation/queries.sql b/internal/conversation/queries.sql index 41a0f8cf..b68f3b5b 100644 --- a/internal/conversation/queries.sql +++ b/internal/conversation/queries.sql @@ -120,6 +120,65 @@ SELECT ) nxt_resp_event ON true WHERE 1=1 %s +-- name: get-conversation-list-item +SELECT + conversations.id, + conversations.created_at, + conversations.updated_at, + conversations.uuid, + conversations.reference_number, + conversations.waiting_since, + users.created_at as "contact.created_at", + users.updated_at as "contact.updated_at", + users.first_name as "contact.first_name", + users.last_name as "contact.last_name", + users.email as "contact.email", + users.avatar_url as "contact.avatar_url", + inboxes.channel as inbox_channel, + inboxes.name as inbox_name, + conversations.sla_policy_id, + conversations.first_reply_at, + conversations.last_reply_at, + conversations.resolved_at, + conversations.subject, + conversations.last_message, + conversations.last_message_at, + conversations.last_message_sender, + conversations.last_interaction, + conversations.last_interaction_at, + conversations.last_interaction_sender, + conversations.next_sla_deadline_at, + conversations.priority_id, + conversations.assigned_user_id, + conversations.assigned_team_id, + conversation_statuses.name as status, + conversation_priorities.name as priority, + as_latest.first_response_deadline_at, + as_latest.resolution_deadline_at, + as_latest.id as applied_sla_id, + nxt_resp_event.deadline_at AS next_response_deadline_at, + nxt_resp_event.met_at as next_response_met_at +FROM conversations +JOIN users ON contact_id = users.id +JOIN inboxes ON inbox_id = inboxes.id +LEFT JOIN conversation_statuses ON status_id = conversation_statuses.id +LEFT JOIN conversation_priorities ON priority_id = conversation_priorities.id +LEFT JOIN LATERAL ( + SELECT id, first_response_deadline_at, resolution_deadline_at + FROM applied_slas + WHERE conversation_id = conversations.id + ORDER BY created_at DESC LIMIT 1 +) as_latest ON true +LEFT JOIN LATERAL ( + SELECT se.deadline_at, se.met_at + FROM sla_events se + WHERE se.applied_sla_id = as_latest.id + AND se.type = 'next_response' + ORDER BY se.created_at DESC + LIMIT 1 +) nxt_resp_event ON true +WHERE conversations.uuid = $1::uuid; + -- name: get-conversation SELECT c.id, diff --git a/internal/conversation/ws.go b/internal/conversation/ws.go index 851bc73a..07fef8b1 100644 --- a/internal/conversation/ws.go +++ b/internal/conversation/ws.go @@ -11,24 +11,31 @@ import ( wsmodels "github.com/abhinavxd/libredesk/internal/ws/models" ) -// BroadcastNewConversation signals all agents that a new conversation exists. -func (m *Manager) BroadcastNewConversation() { - m.broadcastToUsers([]int{}, wsmodels.Message{ +func (m *Manager) BroadcastNewConversation(conv *cmodels.ConversationListItem) { + userIDs := m.AuthorizedConnectedAgentIDs(conv.AssignedUserID, conv.AssignedTeamID) + if len(userIDs) == 0 { + return + } + m.broadcastToUsers(userIDs, wsmodels.Message{ Type: wsmodels.MessageTypeNewConversation, + Data: convToBroadcastMap(conv), }) } -// BroadcastNewMessage broadcasts a new message to subscribers. -func (m *Manager) BroadcastNewMessage(message *cmodels.Message, preview string) { +func (m *Manager) BroadcastNewMessage(message *cmodels.Message, conv *cmodels.ConversationListItem, preview string) { + if conv == nil { + return + } data := map[string]any{ "conversation_uuid": message.ConversationUUID, "uuid": message.UUID, + "type": message.Type, "preview": preview, "created_at": message.CreatedAt.Format(time.RFC3339), "sender_type": message.SenderType, + "conversation": convToBroadcastMap(conv), } - // Include echo_id from meta so clients can match WS events to pending messages. var meta map[string]any if len(message.Meta) > 0 { if err := json.Unmarshal(message.Meta, &meta); err == nil { @@ -38,7 +45,11 @@ func (m *Manager) BroadcastNewMessage(message *cmodels.Message, preview string) } } - m.broadcastToConversationListSubs(message.ConversationUUID, wsmodels.Message{ + userIDs := m.AuthorizedConnectedAgentIDs(conv.AssignedUserID, conv.AssignedTeamID) + if len(userIDs) == 0 { + return + } + m.broadcastToUsers(userIDs, wsmodels.Message{ Type: wsmodels.MessageTypeNewMessage, Data: data, }) @@ -174,6 +185,16 @@ func (m *Manager) broadcastTypingToWidgetClients(conversationUUID string, isTypi } } +func (m *Manager) BroadcastAgentAvailability(agentID int, status string) { + m.broadcastToUsers([]int{}, wsmodels.Message{ + Type: wsmodels.MessageTypeAgentAvailability, + Data: map[string]any{ + "agent_id": agentID, + "availability_status": status, + }, + }) +} + // BroadcastAgentStatusToWidget sends a lightweight assignee availability update // to widget clients for all active livechat conversations assigned to the given agent. func (m *Manager) BroadcastAgentStatusToWidget(agentID int, status string) { @@ -209,3 +230,20 @@ func (m *Manager) BroadcastConversationToWidget(conversationUUID string, contact liveChatInbox.BroadcastConversationToClients(conversationUUID, contactID, data) } } + +func convToBroadcastMap(conv *cmodels.ConversationListItem) map[string]any { + if conv == nil { + return nil + } + b, err := json.Marshal(conv) + if err != nil { + return nil + } + var out map[string]any + if err := json.Unmarshal(b, &out); err != nil { + return nil + } + delete(out, "unread_message_count") + delete(out, "mentioned_message_uuid") + return out +} diff --git a/internal/sla/sla.go b/internal/sla/sla.go index fe22eacf..52ced93f 100644 --- a/internal/sla/sla.go +++ b/internal/sla/sla.go @@ -514,7 +514,6 @@ func (m *Manager) SendNotifications(ctx context.Context) error { m.lo.Error("error sending notification", "error", err) } } - m.lo.Info("sent SLA notifications", "count", len(notifications)) } <-ticker.C } diff --git a/internal/user/models/models.go b/internal/user/models/models.go index 9d816447..e95f2232 100644 --- a/internal/user/models/models.go +++ b/internal/user/models/models.go @@ -32,16 +32,17 @@ const ( ) type UserCompact struct { - ID int `db:"id" json:"id"` - Type string `db:"type" json:"type"` - FirstName string `db:"first_name" json:"first_name"` - LastName string `db:"last_name" json:"last_name"` - Email null.String `db:"email" json:"email"` - Enabled bool `db:"enabled" json:"enabled"` - AvatarURL null.String `db:"avatar_url" json:"avatar_url"` - ExternalUserID null.String `db:"external_user_id" json:"external_user_id"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ID int `db:"id" json:"id"` + Type string `db:"type" json:"type"` + FirstName string `db:"first_name" json:"first_name"` + LastName string `db:"last_name" json:"last_name"` + Email null.String `db:"email" json:"email"` + Enabled bool `db:"enabled" json:"enabled"` + AvatarURL null.String `db:"avatar_url" json:"avatar_url"` + ExternalUserID null.String `db:"external_user_id" json:"external_user_id"` + AvailabilityStatus string `db:"availability_status" json:"availability_status"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` Total int `db:"total" json:"-"` } diff --git a/internal/user/queries.sql b/internal/user/queries.sql index 9417e63a..8c850c40 100644 --- a/internal/user/queries.sql +++ b/internal/user/queries.sql @@ -1,5 +1,5 @@ -- name: get-users-compact -SELECT COUNT(*) OVER() as total, users.id, users.avatar_url, users.type, users.created_at, users.updated_at, users.first_name, users.last_name, users.email, users.enabled, users.external_user_id +SELECT COUNT(*) OVER() as total, users.id, users.avatar_url, users.type, users.created_at, users.updated_at, users.first_name, users.last_name, users.email, users.enabled, users.external_user_id, users.availability_status FROM users WHERE users.email != 'System' AND users.deleted_at IS NULL AND type = ANY($1) diff --git a/internal/ws/models/models.go b/internal/ws/models/models.go index be105959..49e8d743 100644 --- a/internal/ws/models/models.go +++ b/internal/ws/models/models.go @@ -11,6 +11,7 @@ const ( MessageTypeConversationSubscribe = "conversation_subscribe" MessageTypeTyping = "typing" MessageTypeListSubscribeReplace = "list_subscribe_replace" + MessageTypeAgentAvailability = "agent_availability_update" ) // WSMessage represents a WS message. diff --git a/internal/ws/ws.go b/internal/ws/ws.go index b47e06a9..458e7792 100644 --- a/internal/ws/ws.go +++ b/internal/ws/ws.go @@ -168,6 +168,18 @@ func (h *Hub) RemoveClient(client *Client) { h.ClearClientSubs(client) } +func (h *Hub) ConnectedUserIDs() []int { + h.clientsMutex.RLock() + defer h.clientsMutex.RUnlock() + out := make([]int, 0, len(h.clients)) + for id, clients := range h.clients { + if len(clients) > 0 { + out = append(out, id) + } + } + return out +} + // PushToClients sends a raw payload directly to the given client connections. func (h *Hub) PushToClients(clients []*Client, data []byte) { for _, c := range clients { From 183c83b5e699313395219204a5a7f7d08ac7c11d Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Fri, 22 May 2026 21:29:57 +0530 Subject: [PATCH 16/97] fix: prod crash from private methods, plus PR #336 review fixes --- .../src/features/contact/ContactNotes.vue | 10 ++++-- frontend/apps/main/src/stores/conversation.js | 3 +- .../src/utils/conversation-message-cache.js | 14 ++++---- .../apps/main/src/utils/delayed-loading.js | 6 ++++ .../conversation/ConversationDetailView.vue | 6 ++-- internal/conversation/conversation.go | 28 ++++++++++----- internal/conversation/ws.go | 36 +++++++++++++++++++ 7 files changed, 82 insertions(+), 21 deletions(-) diff --git a/frontend/apps/main/src/features/contact/ContactNotes.vue b/frontend/apps/main/src/features/contact/ContactNotes.vue index 9f9604c7..3af42183 100644 --- a/frontend/apps/main/src/features/contact/ContactNotes.vue +++ b/frontend/apps/main/src/features/contact/ContactNotes.vue @@ -253,8 +253,11 @@ const cancelAddNote = () => { } const addOrUpdateNote = async () => { + const targetId = props.contactId + if (!targetId) return try { - await api.createContactNote(props.contactId, { note: newNote.value }) + await api.createContactNote(targetId, { note: newNote.value }) + notesCache.delete(targetId) await fetchNotes(props.contactId, { useCache: false }) cancelAddNote() } catch (error) { @@ -266,14 +269,17 @@ const addOrUpdateNote = async () => { } const deleteNote = async (noteId) => { + const targetId = props.contactId + if (!targetId) return try { - await api.deleteContactNote(props.contactId, noteId) + await api.deleteContactNote(targetId, noteId) } catch (error) { emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { variant: 'destructive', description: handleHTTPError(error).message }) } finally { + notesCache.delete(targetId) await fetchNotes(props.contactId, { useCache: false }) } } diff --git a/frontend/apps/main/src/stores/conversation.js b/frontend/apps/main/src/stores/conversation.js index b4a87d99..9ef2f5b8 100644 --- a/frontend/apps/main/src/stores/conversation.js +++ b/frontend/apps/main/src/stores/conversation.js @@ -788,7 +788,7 @@ export const useConversationStore = defineStore('conversation', () => { document.addEventListener('visibilitychange', () => { if (!document.hidden) refreshConversationList() }) - setInterval(refreshConversationList, 30000) + setInterval(refreshConversationList, 60000) } function updateConversationLastMessage (uuid, message) { @@ -945,6 +945,7 @@ export const useConversationStore = defineStore('conversation', () => { if (conversations.status !== '' && payload.status !== conversations.status) return if (!conversations.data) conversations.data = [] conversations.data.unshift(payload) + conversations.total += 1 trimListToCurrentPage() } diff --git a/frontend/apps/main/src/utils/conversation-message-cache.js b/frontend/apps/main/src/utils/conversation-message-cache.js index aab4bc6a..f55b3106 100644 --- a/frontend/apps/main/src/utils/conversation-message-cache.js +++ b/frontend/apps/main/src/utils/conversation-message-cache.js @@ -31,7 +31,7 @@ export default class MessageCache { } hasMessage (convId, msgId) { - return this.#allMessages(convId).some(m => m.uuid === msgId) + return this._allMessages(convId).some(m => m.uuid === msgId) } addMessage (convId, message) { @@ -45,12 +45,12 @@ export default class MessageCache { } getAllPagesMessages (convId) { - return this.#allMessages(convId) + return this._allMessages(convId) .sort((a, b) => new Date(a.created_at) - new Date(b.created_at)) } getLatestMessage (convId, type = [], excludePrivate = false, excludeAutomated = false) { - const filtered = this.#allMessages(convId).filter(msg => { + const filtered = this._allMessages(convId).filter(msg => { if (type.length > 0 && !type.includes(msg.type)) return false if (excludePrivate && msg.private) return false if (excludeAutomated && msg.meta?.is_automated) return false @@ -61,11 +61,11 @@ export default class MessageCache { } updateMessage (convId, msgId, updates) { - this.#updateMessageBy(convId, msgId, msg => Object.assign(msg, updates)) + this._updateMessageBy(convId, msgId, msg => Object.assign(msg, updates)) } updateMessageField (convId, msgId, field, value) { - this.#updateMessageBy(convId, msgId, msg => { msg[field] = value }) + this._updateMessageBy(convId, msgId, msg => { msg[field] = value }) } removeMessage (convId, msgId) { @@ -99,13 +99,13 @@ export default class MessageCache { return this.cache.has(convId) } - #allMessages (convId) { + _allMessages (convId) { const conv = this.cache.get(convId) if (!conv) return [] return Array.from(conv.pages.values()).flat() } - #updateMessageBy (convId, msgId, mutate) { + _updateMessageBy (convId, msgId, mutate) { const conv = this.cache.get(convId) if (!conv) return conv.pages.forEach(msgs => { diff --git a/frontend/apps/main/src/utils/delayed-loading.js b/frontend/apps/main/src/utils/delayed-loading.js index 9dd53693..1dcd54a8 100644 --- a/frontend/apps/main/src/utils/delayed-loading.js +++ b/frontend/apps/main/src/utils/delayed-loading.js @@ -41,6 +41,12 @@ export function delayedLoading (target, key, delayMs = 300, minVisibleMs = 300) }, cancel () { if (showTimer) clearTimeout(showTimer) + const pendingHide = pendingHides.get(target)?.get(key) + if (pendingHide !== undefined) { + clearTimeout(pendingHide) + pendingHides.get(target).delete(key) + } + target[key] = false } } } diff --git a/frontend/apps/main/src/views/conversation/ConversationDetailView.vue b/frontend/apps/main/src/views/conversation/ConversationDetailView.vue index f20fcaac..25ffb8d1 100644 --- a/frontend/apps/main/src/views/conversation/ConversationDetailView.vue +++ b/frontend/apps/main/src/views/conversation/ConversationDetailView.vue @@ -139,15 +139,15 @@ onMounted(() => { watch( () => props.uuid, - (newUUID, oldUUID) => { + async (newUUID, oldUUID) => { if (!newUUID || newUUID === oldUUID) return const canTransition = oldUUID && typeof document.startViewTransition === 'function' if (!canTransition) { - fetchConversation(newUUID) + await fetchConversation(newUUID) return } document.startViewTransition(async () => { - fetchConversation(newUUID) + await fetchConversation(newUUID) await nextTick() }) } diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index 6788217b..1e034e97 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -783,6 +783,7 @@ func (c *Manager) UpdateConversationTeamAssignee(uuid string, teamID int, actor // UpdateAssignee updates the assignee of a conversation. func (c *Manager) UpdateAssignee(uuid string, assigneeID int, assigneeType string) error { + prev, prevErr := c.GetConversationListItem(uuid) switch assigneeType { case models.AssigneeTypeUser: if _, err := c.q.UpdateConversationAssignedUser.Exec(uuid, assigneeID); err != nil { @@ -797,11 +798,16 @@ func (c *Manager) UpdateAssignee(uuid string, assigneeID int, assigneeType strin default: return fmt.Errorf("invalid assignee type: %s", assigneeType) } - if item, err := c.GetConversationListItem(uuid); err == nil { - c.BroadcastNewConversation(&item) - } else { - c.lo.Error("error fetching conversation list item for assignee broadcast", "uuid", uuid, "error", err) + next, nextErr := c.GetConversationListItem(uuid) + if nextErr != nil { + c.lo.Error("error fetching conversation list item for assignee broadcast", "uuid", uuid, "error", nextErr) + return nil } + var prevPtr *models.ConversationListItem + if prevErr == nil { + prevPtr = &prev + } + c.BroadcastConvReassignment(prevPtr, &next) return nil } @@ -1353,6 +1359,7 @@ func (m *Manager) ApplyAction(action amodels.RuleAction, conv models.Conversatio // RemoveConversationAssignee removes assigned user from a conversation. func (m *Manager) RemoveConversationAssignee(uuid, typ string, actor umodels.User) error { + prev, prevErr := m.GetConversationListItem(uuid) if _, err := m.q.RemoveConversationAssignee.Exec(uuid, typ); err != nil { m.lo.Error("error removing conversation assignee", "error", err) return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.errorUpdatingConversation"), nil) @@ -1378,11 +1385,16 @@ func (m *Manager) RemoveConversationAssignee(uuid, typ string, actor umodels.Use } } - if item, err := m.GetConversationListItem(uuid); err == nil { - m.BroadcastNewConversation(&item) - } else { - m.lo.Error("error fetching conversation list item for unassign broadcast", "uuid", uuid, "error", err) + next, nextErr := m.GetConversationListItem(uuid) + if nextErr != nil { + m.lo.Error("error fetching conversation list item for unassign broadcast", "uuid", uuid, "error", nextErr) + return nil } + var prevPtr *models.ConversationListItem + if prevErr == nil { + prevPtr = &prev + } + m.BroadcastConvReassignment(prevPtr, &next) return nil } diff --git a/internal/conversation/ws.go b/internal/conversation/ws.go index 07fef8b1..cd1a2d6e 100644 --- a/internal/conversation/ws.go +++ b/internal/conversation/ws.go @@ -12,7 +12,23 @@ import ( ) func (m *Manager) BroadcastNewConversation(conv *cmodels.ConversationListItem) { + m.broadcastConvToAuthorized(conv, nil) +} + +// BroadcastConvReassignment notifies the union of agents authorized under old and new assignee state, so agents losing access receive the updated payload and their frontend can filter the conv out. +func (m *Manager) BroadcastConvReassignment(oldConv, newConv *cmodels.ConversationListItem) { + m.broadcastConvToAuthorized(newConv, oldConv) +} + +func (m *Manager) broadcastConvToAuthorized(conv, oldConv *cmodels.ConversationListItem) { + if conv == nil { + return + } userIDs := m.AuthorizedConnectedAgentIDs(conv.AssignedUserID, conv.AssignedTeamID) + if oldConv != nil { + prevIDs := m.AuthorizedConnectedAgentIDs(oldConv.AssignedUserID, oldConv.AssignedTeamID) + userIDs = unionIntSlices(userIDs, prevIDs) + } if len(userIDs) == 0 { return } @@ -247,3 +263,23 @@ func convToBroadcastMap(conv *cmodels.ConversationListItem) map[string]any { delete(out, "mentioned_message_uuid") return out } + +func unionIntSlices(a, b []int) []int { + seen := make(map[int]struct{}, len(a)+len(b)) + out := make([]int, 0, len(a)+len(b)) + for _, id := range a { + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + for _, id := range b { + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} From 46d56800b1905d11698b1a7d6135220763987966 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Fri, 22 May 2026 22:17:48 +0530 Subject: [PATCH 17/97] fix: skeleton not showing on slow network - View Transition was freezing render `await fetchConversation` inside `document.startViewTransition` held the BEFORE snapshot for the full fetch duration, hiding the in-flight skeleton. Reverting to fire-and-forget; the view transition completes instantly and the fetch runs in background so delayedLoading's 300ms skeleton renders normally. --- .../main/src/views/conversation/ConversationDetailView.vue | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/apps/main/src/views/conversation/ConversationDetailView.vue b/frontend/apps/main/src/views/conversation/ConversationDetailView.vue index 25ffb8d1..f20fcaac 100644 --- a/frontend/apps/main/src/views/conversation/ConversationDetailView.vue +++ b/frontend/apps/main/src/views/conversation/ConversationDetailView.vue @@ -139,15 +139,15 @@ onMounted(() => { watch( () => props.uuid, - async (newUUID, oldUUID) => { + (newUUID, oldUUID) => { if (!newUUID || newUUID === oldUUID) return const canTransition = oldUUID && typeof document.startViewTransition === 'function' if (!canTransition) { - await fetchConversation(newUUID) + fetchConversation(newUUID) return } document.startViewTransition(async () => { - await fetchConversation(newUUID) + fetchConversation(newUUID) await nextTick() }) } From e4fae78c2bff459a649b3ed072422d461dd30425 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Fri, 22 May 2026 22:23:38 +0530 Subject: [PATCH 18/97] tweak: bump skeleton minVisibleMs 300 -> 500ms --- frontend/apps/main/src/utils/delayed-loading.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/apps/main/src/utils/delayed-loading.js b/frontend/apps/main/src/utils/delayed-loading.js index 1dcd54a8..1fbce90e 100644 --- a/frontend/apps/main/src/utils/delayed-loading.js +++ b/frontend/apps/main/src/utils/delayed-loading.js @@ -1,6 +1,6 @@ const pendingHides = new WeakMap() -export function delayedLoading (target, key, delayMs = 300, minVisibleMs = 300) { +export function delayedLoading (target, key, delayMs = 300, minVisibleMs = 500) { const prevHide = pendingHides.get(target)?.get(key) if (prevHide !== undefined) { clearTimeout(prevHide) From ad089fc79f90bd701738e83e77fbc8d533474b7d Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Fri, 22 May 2026 22:37:29 +0530 Subject: [PATCH 19/97] fix: hide conv list items while skeleton is visible --- .../main/src/features/conversation/list/ConversationList.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/apps/main/src/features/conversation/list/ConversationList.vue b/frontend/apps/main/src/features/conversation/list/ConversationList.vue index e87c503b..241d2d9a 100644 --- a/frontend/apps/main/src/features/conversation/list/ConversationList.vue +++ b/frontend/apps/main/src/features/conversation/list/ConversationList.vue @@ -101,7 +101,7 @@ leave-to-class="opacity-0 transform translate-y-4" >

Date: Sat, 23 May 2026 12:36:57 +0000 Subject: [PATCH 20/97] build(deps): bump qs from 6.14.2 to 6.15.2 in /frontend Bumps [qs](https://github.com/ljharb/qs) from 6.14.2 to 6.15.2. - [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md) - [Commits](https://github.com/ljharb/qs/compare/v6.14.2...v6.15.2) --- updated-dependencies: - dependency-name: qs dependency-version: 6.15.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- frontend/pnpm-lock.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index b56adacf..fbc527a5 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -2146,8 +2146,8 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} es-set-tostringtag@2.1.0: @@ -5946,7 +5946,7 @@ snapshots: es-module-lexer@1.7.0: {} - es-object-atoms@1.1.1: + es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 @@ -6253,7 +6253,7 @@ snapshots: call-bind-apply-helpers: 1.0.2 es-define-property: 1.0.1 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 function-bind: 1.1.2 get-proto: 1.0.1 gopd: 1.2.0 @@ -6264,7 +6264,7 @@ snapshots: get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 get-stream@5.2.0: dependencies: From 2874e78e97674791276680b5ba8ec77f25cee20c Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Mon, 25 May 2026 01:59:56 +0530 Subject: [PATCH 21/97] fix: mark-unread no-op when latest message is a continuity email --- internal/conversation/queries.sql | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/internal/conversation/queries.sql b/internal/conversation/queries.sql index b68f3b5b..95114dcb 100644 --- a/internal/conversation/queries.sql +++ b/internal/conversation/queries.sql @@ -872,19 +872,21 @@ INSERT INTO conversation_mentions (conversation_id, message_id, mentioned_user_i VALUES ($1, $2, $3, $4, $5); -- name: mark-conversation-unread -INSERT INTO conversation_last_seen (user_id, conversation_id, last_seen_at) -VALUES ( - $1, - (SELECT id FROM conversations WHERE uuid = $2), - (SELECT created_at - INTERVAL '1 second' FROM conversation_messages - WHERE conversation_id = (SELECT id FROM conversations WHERE uuid = $2) - ORDER BY created_at DESC LIMIT 1) +WITH target AS ( + SELECT id FROM conversations WHERE uuid = $2 +), +last_msg AS ( + SELECT created_at - INTERVAL '1 second' AS ts + FROM conversation_messages + WHERE conversation_id = (SELECT id FROM target) + AND (meta IS NULL OR NOT COALESCE((meta->>'continuity_email')::boolean, false)) + ORDER BY created_at DESC LIMIT 1 ) +INSERT INTO conversation_last_seen (user_id, conversation_id, last_seen_at) +SELECT $1, (SELECT id FROM target), ts FROM last_msg ON CONFLICT (conversation_id, user_id) DO UPDATE SET - last_seen_at = (SELECT created_at - INTERVAL '1 second' FROM conversation_messages - WHERE conversation_id = (SELECT id FROM conversations WHERE uuid = $2) - ORDER BY created_at DESC LIMIT 1), + last_seen_at = EXCLUDED.last_seen_at, updated_at = NOW(); -- name: get-active-livechat-conversations-by-agent From 92fe4f453922d6f7a09e8497934f050d4f2cf08c Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Mon, 25 May 2026 23:18:34 +0530 Subject: [PATCH 22/97] tweak: scope conv list refresh interval to InboxView, bump to 2m --- frontend/apps/main/src/stores/conversation.js | 7 ------- frontend/apps/main/src/views/inbox/InboxView.vue | 16 ++++++++++++++++ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/frontend/apps/main/src/stores/conversation.js b/frontend/apps/main/src/stores/conversation.js index 9ef2f5b8..0973d799 100644 --- a/frontend/apps/main/src/stores/conversation.js +++ b/frontend/apps/main/src/stores/conversation.js @@ -784,13 +784,6 @@ export const useConversationStore = defineStore('conversation', () => { throttledFetchFirstPage() } - if (typeof document !== 'undefined') { - document.addEventListener('visibilitychange', () => { - if (!document.hidden) refreshConversationList() - }) - setInterval(refreshConversationList, 60000) - } - function updateConversationLastMessage (uuid, message) { const conv = conversations.data?.find(c => c.uuid === uuid) if (!conv) return diff --git a/frontend/apps/main/src/views/inbox/InboxView.vue b/frontend/apps/main/src/views/inbox/InboxView.vue index 6c0e1ef8..bbb1323e 100644 --- a/frontend/apps/main/src/views/inbox/InboxView.vue +++ b/frontend/apps/main/src/views/inbox/InboxView.vue @@ -6,6 +6,7 @@ From 9f09d8c521d1b1f17de09c9b52ad0e0fca5850b6 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Tue, 26 May 2026 03:06:35 +0530 Subject: [PATCH 26/97] fix: dedupe parallel custom-attribute fetch, cache AI prompts in store --- .../src/features/conversation/ReplyBox.vue | 21 +++--------- frontend/apps/main/src/stores/aiPrompt.js | 34 +++++++++++++++++++ .../apps/main/src/stores/customAttributes.js | 25 +++++++++----- 3 files changed, 54 insertions(+), 26 deletions(-) create mode 100644 frontend/apps/main/src/stores/aiPrompt.js diff --git a/frontend/apps/main/src/features/conversation/ReplyBox.vue b/frontend/apps/main/src/features/conversation/ReplyBox.vue index 6927cf8d..d1cabbaa 100644 --- a/frontend/apps/main/src/features/conversation/ReplyBox.vue +++ b/frontend/apps/main/src/features/conversation/ReplyBox.vue @@ -158,6 +158,7 @@ import api from '@main/api' import { useI18n } from 'vue-i18n' import { useConversationStore } from '@main/stores/conversation' import { useInboxStore } from '@main/stores/inbox' +import { useAiPromptStore } from '@main/stores/aiPrompt' import { AlertDialog, AlertDialogAction, @@ -241,28 +242,14 @@ const cc = ref('') const bcc = ref('') const showBcc = ref(false) const emailErrors = ref([]) -const aiPrompts = ref([]) +const aiPromptStore = useAiPromptStore() +const aiPrompts = computed(() => aiPromptStore.prompts) const replyBoxContentRef = ref(null) const showContactEmailWarning = ref(false) const showMissingTagsWarning = ref(false) const mentions = ref([]) -/** - * Fetches AI prompts from the server. - */ -const fetchAiPrompts = async () => { - try { - const resp = await api.getAiPrompts() - aiPrompts.value = resp.data.data - } catch (error) { - emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { - variant: 'destructive', - description: handleHTTPError(error).message - }) - } -} - -fetchAiPrompts() +aiPromptStore.fetchPrompts() /** * Handles the AI prompt selection event. diff --git a/frontend/apps/main/src/stores/aiPrompt.js b/frontend/apps/main/src/stores/aiPrompt.js new file mode 100644 index 00000000..c88c55dd --- /dev/null +++ b/frontend/apps/main/src/stores/aiPrompt.js @@ -0,0 +1,34 @@ +import { ref } from 'vue' +import { defineStore } from 'pinia' +import { handleHTTPError } from '@shared-ui/utils/http.js' +import { useEmitter } from '../composables/useEmitter' +import { EMITTER_EVENTS } from '../constants/emitterEvents' +import api from '../api' + +export const useAiPromptStore = defineStore('aiPrompt', () => { + const prompts = ref([]) + const emitter = useEmitter() + let inflight = null + const fetchPrompts = async () => { + if (prompts.value.length) return + if (inflight) return inflight + inflight = (async () => { + try { + const response = await api.getAiPrompts() + prompts.value = response?.data?.data || [] + } catch (error) { + emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { + variant: 'destructive', + description: handleHTTPError(error).message + }) + } finally { + inflight = null + } + })() + return inflight + } + return { + prompts, + fetchPrompts, + } +}) diff --git a/frontend/apps/main/src/stores/customAttributes.js b/frontend/apps/main/src/stores/customAttributes.js index 916b4d9d..43174be8 100644 --- a/frontend/apps/main/src/stores/customAttributes.js +++ b/frontend/apps/main/src/stores/customAttributes.js @@ -26,17 +26,24 @@ export const useCustomAttributeStore = defineStore('customAttributes', () => { ...att, })) }) + let inflight = null const fetchCustomAttributes = async () => { if (attributes.value.length) return - try { - const response = await api.getCustomAttributes() - attributes.value = response?.data?.data || [] - } catch (error) { - emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { - variant: 'destructive', - description: handleHTTPError(error).message - }) - } + if (inflight) return inflight + inflight = (async () => { + try { + const response = await api.getCustomAttributes() + attributes.value = response?.data?.data || [] + } catch (error) { + emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { + variant: 'destructive', + description: handleHTTPError(error).message + }) + } finally { + inflight = null + } + })() + return inflight } return { attributes, From eb4e49adf00708aed7f45a857e1ca7115539a212 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Tue, 26 May 2026 03:31:49 +0530 Subject: [PATCH 27/97] fix: gate mark-as-read by route, clear stale lastInboxPath on load --- frontend/apps/main/src/App.vue | 3 ++- frontend/apps/main/src/stores/aiPrompt.js | 18 +++++++----------- frontend/apps/main/src/stores/conversation.js | 4 ++++ .../apps/main/src/stores/customAttributes.js | 18 +++++++----------- 4 files changed, 20 insertions(+), 23 deletions(-) diff --git a/frontend/apps/main/src/App.vue b/frontend/apps/main/src/App.vue index fa977eca..37814b4b 100644 --- a/frontend/apps/main/src/App.vue +++ b/frontend/apps/main/src/App.vue @@ -197,7 +197,8 @@ watch( if (path.startsWith('/inboxes') && path !== '/inboxes/search') { lastInboxPath.value = path } - } + }, + { immediate: true } ) const userStore = useUserStore() const conversationStore = useConversationStore() diff --git a/frontend/apps/main/src/stores/aiPrompt.js b/frontend/apps/main/src/stores/aiPrompt.js index c88c55dd..2725972c 100644 --- a/frontend/apps/main/src/stores/aiPrompt.js +++ b/frontend/apps/main/src/stores/aiPrompt.js @@ -9,22 +9,18 @@ export const useAiPromptStore = defineStore('aiPrompt', () => { const prompts = ref([]) const emitter = useEmitter() let inflight = null - const fetchPrompts = async () => { - if (prompts.value.length) return + const fetchPrompts = () => { + if (prompts.value.length) return Promise.resolve() if (inflight) return inflight - inflight = (async () => { - try { - const response = await api.getAiPrompts() - prompts.value = response?.data?.data || [] - } catch (error) { + inflight = api.getAiPrompts() + .then(response => { prompts.value = response?.data?.data || [] }) + .catch(error => { emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { variant: 'destructive', description: handleHTTPError(error).message }) - } finally { - inflight = null - } - })() + }) + .finally(() => { inflight = null }) return inflight } return { diff --git a/frontend/apps/main/src/stores/conversation.js b/frontend/apps/main/src/stores/conversation.js index 44df8088..1b2904a4 100644 --- a/frontend/apps/main/src/stores/conversation.js +++ b/frontend/apps/main/src/stores/conversation.js @@ -1,5 +1,6 @@ import { defineStore } from 'pinia' import { computed, reactive, ref, watchEffect } from 'vue' +import { useRouter } from 'vue-router' import { handleHTTPError } from '@shared-ui/utils/http.js' import { TYPING_RECEIVE_TIMEOUT } from '@shared-ui/composables/useTypingIndicator.js' import { deepMerge } from '@shared-ui/utils/object.js' @@ -27,6 +28,8 @@ export const useConversationStore = defineStore('conversation', () => { const macros = ref({}) const drafts = ref(new Map()) const userStore = useUserStore() + const router = useRouter() + const isViewingConversation = (uuid) => router.currentRoute.value.params.uuid === uuid const selectedUUIDs = ref(new Set()) @@ -763,6 +766,7 @@ export const useConversationStore = defineStore('conversation', () => { } async function updateAssigneeLastSeen (uuid) { + if (!isViewingConversation(uuid)) return markConversationAsRead(uuid) api.updateAssigneeLastSeen(uuid).catch(() => { }) } diff --git a/frontend/apps/main/src/stores/customAttributes.js b/frontend/apps/main/src/stores/customAttributes.js index 43174be8..82ec8450 100644 --- a/frontend/apps/main/src/stores/customAttributes.js +++ b/frontend/apps/main/src/stores/customAttributes.js @@ -27,22 +27,18 @@ export const useCustomAttributeStore = defineStore('customAttributes', () => { })) }) let inflight = null - const fetchCustomAttributes = async () => { - if (attributes.value.length) return + const fetchCustomAttributes = () => { + if (attributes.value.length) return Promise.resolve() if (inflight) return inflight - inflight = (async () => { - try { - const response = await api.getCustomAttributes() - attributes.value = response?.data?.data || [] - } catch (error) { + inflight = api.getCustomAttributes() + .then(response => { attributes.value = response?.data?.data || [] }) + .catch(error => { emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { variant: 'destructive', description: handleHTTPError(error).message }) - } finally { - inflight = null - } - })() + }) + .finally(() => { inflight = null }) return inflight } return { From 214cc603749ba66fa965e6766d61891e1802c551 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Tue, 26 May 2026 12:07:34 +0530 Subject: [PATCH 28/97] fix: derive conversation list link context from route params, not name substring route.name.includes('view') also matched 'overview', causing vue-router to throw "Missing required param viewID" when the kept-alive conversation list recomputed its links from /reports/overview. Switch to checking route.params.teamID / route.params.viewID directly. --- .../src/features/conversation/list/ConversationListItem.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/apps/main/src/features/conversation/list/ConversationListItem.vue b/frontend/apps/main/src/features/conversation/list/ConversationListItem.vue index 1d6581e2..2710bb6d 100644 --- a/frontend/apps/main/src/features/conversation/list/ConversationListItem.vue +++ b/frontend/apps/main/src/features/conversation/list/ConversationListItem.vue @@ -190,9 +190,9 @@ const handleMarkAsUnread = () => { } const conversationRoute = computed(() => { - const baseRoute = route.name.includes('team') + const baseRoute = route.params.teamID ? 'team-inbox-conversation' - : route.name.includes('view') + : route.params.viewID ? 'view-inbox-conversation' : 'inbox-conversation' return { From f58149291d94ded9a2550579a021464dde6252a5 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Tue, 26 May 2026 12:52:07 +0530 Subject: [PATCH 29/97] refactor: dedupe overview report fetch/handler boilerplate --- .../main/src/views/reports/OverviewView.vue | 180 +++++++----------- 1 file changed, 66 insertions(+), 114 deletions(-) diff --git a/frontend/apps/main/src/views/reports/OverviewView.vue b/frontend/apps/main/src/views/reports/OverviewView.vue index e7fefa07..8ec773f3 100644 --- a/frontend/apps/main/src/views/reports/OverviewView.vue +++ b/frontend/apps/main/src/views/reports/OverviewView.vue @@ -35,7 +35,7 @@

{{ $t('report.csat.cardTitle', { days: csatDays }) }}

- +
@@ -61,7 +61,10 @@

{{ $t('report.messages.cardTitle', { days: messageVolumeDays }) }}

- +
@@ -96,7 +99,7 @@

{{ slaCardTitle }}

- +
@@ -201,7 +204,10 @@

{{ $t('report.tags.cardTitle', { days: tagDistributionDays }) }}

- +
@@ -240,7 +246,7 @@

{{ $t('report.chart.title') }}

- +
@@ -265,6 +271,7 @@ import api from '../../api' const emitter = useEmitter() const { t } = useI18n() const isLoading = ref(false) +const initialized = ref(false) const lastUpdate = ref(new Date()) const cardCounts = ref({}) const chartData = ref({ status_summary: [] }) @@ -292,7 +299,6 @@ const slaCounts = ref({ resolution_compliance_percent: 0 }) -// New data refs const csatData = ref({ average_rating: 0, response_rate: 0, @@ -314,14 +320,53 @@ const tagDistributionData = ref({ tagged_percentage: 0 }) -// Date filter state -const slaDays = ref(30) -const chartDays = ref(90) -const csatDays = ref(30) -const messageVolumeDays = ref(30) -const tagDistributionDays = ref(30) +const sections = { + sla: { + days: ref(30), + fetch: async (days) => { + const { data } = await api.getOverviewSLA({ days }) + slaCounts.value = { ...slaCounts.value, ...data.data } + } + }, + chart: { + days: ref(90), + fetch: async (days) => { + const { data } = await api.getOverviewCharts({ days }) + chartData.value = { + new_conversations: data.data.new_conversations || [], + resolved_conversations: data.data.resolved_conversations || [], + messages_sent: data.data.messages_sent || [] + } + } + }, + csat: { + days: ref(30), + fetch: async (days) => { + const { data } = await api.getOverviewCSAT({ days }) + csatData.value = { ...csatData.value, ...data.data } + } + }, + messageVolume: { + days: ref(30), + fetch: async (days) => { + const { data } = await api.getOverviewMessageVolume({ days }) + messageVolumeData.value = { ...messageVolumeData.value, ...data.data } + } + }, + tagDistribution: { + days: ref(30), + fetch: async (days) => { + const { data } = await api.getOverviewTagDistribution({ days }) + tagDistributionData.value = { ...tagDistributionData.value, ...data.data } + } + } +} + +const slaDays = sections.sla.days +const csatDays = sections.csat.days +const messageVolumeDays = sections.messageVolume.days +const tagDistributionDays = sections.tagDistribution.days -// Format helpers const formatRating = (value) => { if (!value) return '0.0' return Number(value).toFixed(1) @@ -346,7 +391,6 @@ const formattedSlaCounts = computed(() => ({ avg_resolution_time_sec: formatDuration(slaCounts.value.avg_resolution_time_sec, false) })) -// Dynamic SLA card title based on selected days const slaCardTitle = computed(() => t('report.sla.cardTitle', { days: slaDays.value })) const lastUpdateFormatted = computed(() => lastUpdate.value.toLocaleTimeString()) @@ -415,105 +459,20 @@ const fetchCardStats = async () => { } } -const fetchSLAStats = async (days = slaDays.value) => { +const runSectionFetch = async (key, days) => { try { - const { data } = await api.getOverviewSLA({ days }) - slaCounts.value = { ...slaCounts.value, ...data.data } + await sections[key].fetch(days) } catch (error) { showError(error) } } -const fetchChartData = async (days = chartDays.value) => { - try { - const { data } = await api.getOverviewCharts({ days }) - chartData.value = { - new_conversations: data.data.new_conversations || [], - resolved_conversations: data.data.resolved_conversations || [], - messages_sent: data.data.messages_sent || [] - } - } catch (error) { - showError(error) - } -} - -const fetchCSATStats = async (days = csatDays.value) => { - try { - const { data } = await api.getOverviewCSAT({ days }) - csatData.value = { ...csatData.value, ...data.data } - } catch (error) { - showError(error) - } -} - -const fetchMessageVolumeStats = async (days = messageVolumeDays.value) => { - try { - const { data } = await api.getOverviewMessageVolume({ days }) - messageVolumeData.value = { ...messageVolumeData.value, ...data.data } - } catch (error) { - showError(error) - } -} - -const fetchTagDistributionStats = async (days = tagDistributionDays.value) => { - try { - const { data } = await api.getOverviewTagDistribution({ days }) - tagDistributionData.value = { ...tagDistributionData.value, ...data.data } - } catch (error) { - showError(error) - } -} - -// Date filter handlers -const handleSlaFilterChange = async (days) => { - slaDays.value = days +const handleFilterChange = async (key, days) => { + if (!initialized.value) return + sections[key].days.value = days isLoading.value = true try { - await fetchSLAStats(days) - } finally { - isLoading.value = false - lastUpdate.value = new Date() - } -} - -const handleChartFilterChange = async (days) => { - chartDays.value = days - isLoading.value = true - try { - await fetchChartData(days) - } finally { - isLoading.value = false - lastUpdate.value = new Date() - } -} - -const handleCSATFilterChange = async (days) => { - csatDays.value = days - isLoading.value = true - try { - await fetchCSATStats(days) - } finally { - isLoading.value = false - lastUpdate.value = new Date() - } -} - -const handleMessageVolumeFilterChange = async (days) => { - messageVolumeDays.value = days - isLoading.value = true - try { - await fetchMessageVolumeStats(days) - } finally { - isLoading.value = false - lastUpdate.value = new Date() - } -} - -const handleTagDistributionFilterChange = async (days) => { - tagDistributionDays.value = days - isLoading.value = true - try { - await fetchTagDistributionStats(days) + await runSectionFetch(key, days) } finally { isLoading.value = false lastUpdate.value = new Date() @@ -525,15 +484,12 @@ const loadDashboardData = async () => { try { await Promise.allSettled([ fetchCardStats(), - fetchSLAStats(), - fetchChartData(), - fetchCSATStats(), - fetchMessageVolumeStats(), - fetchTagDistributionStats() + ...Object.keys(sections).map((key) => runSectionFetch(key, sections[key].days.value)) ]) } finally { isLoading.value = false lastUpdate.value = new Date() + initialized.value = true } } @@ -564,10 +520,6 @@ onUnmounted(() => { @apply text-3xl font-bold tracking-tight; } -.metric-value { - @apply text-3xl font-bold tracking-tight; -} - .metric-label { @apply text-xs text-muted-foreground uppercase tracking-wider; } From 0654e3bd2f0647b3777b3ac448e1b6122f3191bd Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Wed, 27 May 2026 00:24:27 +0530 Subject: [PATCH 30/97] fix: skip empty incoming attachments; assign team/agent before running post message hooks this allows automations to see the conversations assigned agent & team. --- cmd/conversation.go | 16 ++++++++-------- internal/conversation/message.go | 10 +++++++--- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/cmd/conversation.go b/cmd/conversation.go index a591c4a4..937753b6 100644 --- a/cmd/conversation.go +++ b/cmd/conversation.go @@ -818,6 +818,14 @@ func handleCreateConversation(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError) } + // Assign team first, it clears any assigned agent. + if req.AssignedTeamID > 0 { + app.conversation.UpdateConversationTeamAssignee(conversationUUID, req.AssignedTeamID, user) + } + if req.AssignedAgentID > 0 { + app.conversation.UpdateConversationUserAssignee(conversationUUID, req.AssignedAgentID, user) + } + // Send initial message based on the initiator of conversation. switch req.Initiator { case umodels.UserTypeAgent: @@ -847,14 +855,6 @@ func handleCreateConversation(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError) } - // Assign the conversation to team/agent if provided, always assign team first as it clears assigned agent. - if req.AssignedTeamID > 0 { - app.conversation.UpdateConversationTeamAssignee(conversationUUID, req.AssignedTeamID, user) - } - if req.AssignedAgentID > 0 { - app.conversation.UpdateConversationUserAssignee(conversationUUID, req.AssignedAgentID, user) - } - conversation, _ := app.conversation.GetConversation(conversationID, "", "") return r.SendEnvelope(conversation) } diff --git a/internal/conversation/message.go b/internal/conversation/message.go index 2f1195fc..b2fe429e 100644 --- a/internal/conversation/message.go +++ b/internal/conversation/message.go @@ -785,9 +785,9 @@ func (m *Manager) ProcessIncomingMessage(in models.IncomingMessage) (models.Mess // Upload message attachments. On failure, delete the conversation if it was just created for this message. if upErr := m.uploadMessageAttachments(&msg); upErr != nil { - m.lo.Error("error uploading message attachments", "message_source_id", in.SourceID, "error", upErr) + m.lo.Error("error uploading message attachments", "message_source_id", in.SourceID.String, "error", upErr) if isNewConversation && conversationUUID != "" { - m.lo.Info("deleting conversation as message attachment upload failed", "conversation_uuid", conversationUUID, "message_source_id", in.SourceID) + m.lo.Info("deleting conversation as message attachment upload failed", "conversation_uuid", conversationUUID, "message_source_id", in.SourceID.String) if err := m.DeleteConversation(conversationUUID); err != nil { return models.Message{}, fmt.Errorf("deleting conversation after message attachment upload failure: %w", err) } @@ -1106,9 +1106,13 @@ func (m *Manager) uploadMessageAttachments(message *models.Message) error { contentID = storedCID } - // Sanitize filename. attachment.Name = stringutil.SanitizeFilename(attachment.Name) + if len(attachment.Content) == 0 { + m.lo.Warn("skipping empty attachment", "name", attachment.Name, "content_id", contentID, "content_type", attachment.ContentType, "disposition", attachment.Disposition, "message_source_id", message.SourceID.String, "conversation_uuid", message.ConversationUUID) + continue + } + m.lo.Debug("uploading message attachment", "name", attachment.Name, "content_id", contentID, "size", attachment.Size, "content_type", attachment.ContentType, "disposition", attachment.Disposition) // Upload and insert entry in media table. From b65d9679aef8ba26d52ba2eaabe28eb002da8d63 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Wed, 27 May 2026 02:14:23 +0530 Subject: [PATCH 31/97] fix: scroll to target message on scrollTo deep-link instead of bottom --- .../conversation/message/MessageList.vue | 35 ++++++------------- .../shared-ui/composables/useStickyScroll.js | 9 ++++- 2 files changed, 19 insertions(+), 25 deletions(-) diff --git a/frontend/apps/main/src/features/conversation/message/MessageList.vue b/frontend/apps/main/src/features/conversation/message/MessageList.vue index b3a82ce1..3d44c751 100644 --- a/frontend/apps/main/src/features/conversation/message/MessageList.vue +++ b/frontend/apps/main/src/features/conversation/message/MessageList.vue @@ -29,7 +29,7 @@
@@ -95,10 +95,8 @@ const contentEl = ref(null) const emitter = useEmitter() const unReadMessages = ref(0) let currentConversationUUID = '' -let pendingScrollTo = null -const { hasUserScrolled, scrollToBottom, handleScroll } = useStickyScroll(threadEl, contentEl, { - skipAutoScroll: () => !!pendingScrollTo, +const { hasUserScrolled, scrollToBottom, scrollToOffset, handleScroll } = useStickyScroll(threadEl, contentEl, { onArriveBottom: () => { unReadMessages.value = 0 } }) @@ -107,18 +105,14 @@ const handleScrollToBottom = () => { scrollToBottom() } -const scrollToMessage = (messageUUID) => { - if (!messageUUID) return scrollToBottom() - const thread = threadEl.value - const messageEl = thread?.querySelector(`[data-message-uuid="${messageUUID}"]`) - if (!messageEl || !thread) { - scrollToBottom() - return +const vScrollTarget = { + mounted (el, binding) { + if (binding.value !== route.query.scrollTo || !threadEl.value) return + hasUserScrolled.value = true + scrollToOffset(Math.max(0, el.offsetTop - threadEl.value.clientHeight / 3 + el.offsetHeight / 2)) + el.classList.add('highlight-mention') + setTimeout(() => el.classList.remove('highlight-mention'), 2500) } - // Position message at ~1/3 from top of viewport for better visibility - thread.scrollTop = Math.max(0, messageEl.offsetTop - thread.clientHeight / 3 + messageEl.offsetHeight / 2) - messageEl.classList.add('highlight-mention') - setTimeout(() => messageEl.classList.remove('highlight-mention'), 2500) } const newMessageHandler = (data) => { @@ -144,21 +138,14 @@ watch( if (!newUUID || newUUID === currentConversationUUID) return currentConversationUUID = newUUID unReadMessages.value = 0 - pendingScrollTo = route.query.scrollTo || null - hasUserScrolled.value = !!pendingScrollTo + hasUserScrolled.value = !!route.query.scrollTo } ) watch( () => conversationStore.conversationMessages.length, (newLen, oldLen) => { - if (pendingScrollTo && newLen > 0) { - const target = pendingScrollTo - pendingScrollTo = null - nextTick(() => scrollToMessage(target)) - return - } - if (oldLen === 0 && newLen > 0) { + if (oldLen === 0 && newLen > 0 && !route.query.scrollTo) { hasUserScrolled.value = false nextTick(scrollToBottom) } diff --git a/frontend/shared-ui/composables/useStickyScroll.js b/frontend/shared-ui/composables/useStickyScroll.js index 35fd9159..491511ae 100644 --- a/frontend/shared-ui/composables/useStickyScroll.js +++ b/frontend/shared-ui/composables/useStickyScroll.js @@ -22,6 +22,13 @@ export function useStickyScroll (scrollEl, contentEl, options = {}) { el.scrollTop = el.scrollHeight } + const scrollToOffset = (top) => { + const el = scrollEl.value + if (!el) return + isProgrammaticScroll = true + el.scrollTop = top + } + const handleScroll = () => { if (isProgrammaticScroll) { isProgrammaticScroll = false @@ -50,5 +57,5 @@ export function useStickyScroll (scrollEl, contentEl, options = {}) { if (resizeObserver) resizeObserver.disconnect() }) - return { hasUserScrolled, scrollToBottom, handleScroll } + return { hasUserScrolled, scrollToBottom, scrollToOffset, handleScroll } } From 8faf06ed999e64485fde5993b3766e6c04d728f7 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Wed, 27 May 2026 19:05:01 +0530 Subject: [PATCH 32/97] fix: reliably sync widget missed messages on reconnect Update connection status banner to show more intermediate connection statuses like connecting.. and connected. --- .../apps/widget/src/components/BaseBanner.vue | 15 ++++ .../src/components/ConnectionBanner.vue | 40 +++++++--- frontend/apps/widget/src/store/widget.js | 21 +++++ frontend/apps/widget/src/websocket.js | 79 +++++++++++++++---- i18n/en-US.json | 2 + 5 files changed, 131 insertions(+), 26 deletions(-) create mode 100644 frontend/apps/widget/src/components/BaseBanner.vue diff --git a/frontend/apps/widget/src/components/BaseBanner.vue b/frontend/apps/widget/src/components/BaseBanner.vue new file mode 100644 index 00000000..a06e48f3 --- /dev/null +++ b/frontend/apps/widget/src/components/BaseBanner.vue @@ -0,0 +1,15 @@ + + + diff --git a/frontend/apps/widget/src/components/ConnectionBanner.vue b/frontend/apps/widget/src/components/ConnectionBanner.vue index 6e0648b6..a162b89e 100644 --- a/frontend/apps/widget/src/components/ConnectionBanner.vue +++ b/frontend/apps/widget/src/components/ConnectionBanner.vue @@ -1,16 +1,32 @@ - - + + diff --git a/frontend/apps/widget/src/store/widget.js b/frontend/apps/widget/src/store/widget.js index 77a87466..1a4603aa 100644 --- a/frontend/apps/widget/src/store/widget.js +++ b/frontend/apps/widget/src/store/widget.js @@ -10,6 +10,9 @@ export const useWidgetStore = defineStore('widget', () => { const isMobileFullScreen = ref(false) const isExpanded = ref(false) const wasExpandedBeforeLeaving = ref(false) + const connectionFailed = ref(false) + const connecting = ref(false) + const connected = ref(false) // Getters @@ -105,6 +108,18 @@ export const useWidgetStore = defineStore('widget', () => { isExpanded.value = expanded } + const setConnectionFailed = (failed) => { + connectionFailed.value = failed + } + + const setConnecting = (value) => { + connecting.value = value + } + + const setConnected = (value) => { + connected.value = value + } + return { // State isOpen, @@ -114,6 +129,9 @@ export const useWidgetStore = defineStore('widget', () => { isMobileFullScreen, isExpanded, wasExpandedBeforeLeaving, + connectionFailed, + connecting, + connected, // Getters isChatView, @@ -130,5 +148,8 @@ export const useWidgetStore = defineStore('widget', () => { expandWidget, collapseWidget, setExpanded, + setConnectionFailed, + setConnecting, + setConnected, } }) diff --git a/frontend/apps/widget/src/websocket.js b/frontend/apps/widget/src/websocket.js index 7c1a47d7..6c23f16f 100644 --- a/frontend/apps/widget/src/websocket.js +++ b/frontend/apps/widget/src/websocket.js @@ -15,16 +15,19 @@ export const WS_EVENT = { CONVERSATION_UPDATE: 'conversation_update', } +// sync retries ~5 min (100 × 3s); WS reconnects every 2s up to ~5 min (150 attempts). +const SYNC_MAX_RETRIES = 100 +const SYNC_RETRY_DELAY_MS = 3000 +const RECONNECT_INTERVAL_MS = 2000 + let widgetWSClient let _syncOnFirstConnect = true export class WidgetWebSocketClient { constructor() { this.socket = null - this.reconnectInterval = 1000 - this.maxReconnectInterval = 30000 this.reconnectAttempts = 0 - this.maxReconnectAttempts = 50 + this.maxReconnectAttempts = 150 this.isReconnecting = false this.reconnectTimer = null this.manualClose = false @@ -34,6 +37,10 @@ export class WidgetWebSocketClient { this.wsInitiated = false this.token = null this.inboxId = null + this.syncRetryTimer = null + this.networkListenersSetup = false + this.recovering = false + this.connectedBannerTimer = null } init (token, inboxId) { @@ -60,10 +67,10 @@ export class WidgetWebSocketClient { } handleOpen () { - this.reconnectInterval = 1000 this.reconnectAttempts = 0 this.isReconnecting = false this.lastPong = Date.now() + useWidgetStore().setConnectionFailed(false) this.setupPing() // Auto-join inbox after connection if inbox_id is set. @@ -77,6 +84,8 @@ export class WidgetWebSocketClient { if (this.wsInitiated || _syncOnFirstConnect) { this.syncMissedMessages() } + + this.finishRecovery() this.wsInitiated = true } @@ -144,12 +153,21 @@ export class WidgetWebSocketClient { handleClose () { this.clearPing() if (!this.manualClose) { + this.beginRecovery() this.reconnect() } } reconnect () { - if (this.isReconnecting || this.reconnectAttempts >= this.maxReconnectAttempts) return + if (this.isReconnecting) return + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + const widgetStore = useWidgetStore() + widgetStore.setConnectionFailed(true) + widgetStore.setConnecting(false) + widgetStore.setConnected(false) + this.recovering = false + return + } this.isReconnecting = true this.reconnectAttempts++ @@ -158,23 +176,45 @@ export class WidgetWebSocketClient { this.isReconnecting = false this.reconnectTimer = null this.connect() - this.reconnectInterval = Math.min(this.reconnectInterval * 1.5, this.maxReconnectInterval) - }, this.reconnectInterval) + }, RECONNECT_INTERVAL_MS) + } + + beginRecovery () { + if (!this.wsInitiated || this.recovering) return + this.recovering = true + clearTimeout(this.connectedBannerTimer) + const widgetStore = useWidgetStore() + widgetStore.setConnected(false) + widgetStore.setConnecting(true) + } + + finishRecovery () { + if (!this.recovering) return + this.recovering = false + const widgetStore = useWidgetStore() + widgetStore.setConnecting(false) + widgetStore.setConnected(true) + clearTimeout(this.connectedBannerTimer) + this.connectedBannerTimer = setTimeout(() => widgetStore.setConnected(false), 2000) } setupNetworkListeners () { + if (this.networkListenersSetup) return + this.networkListenersSetup = true + window.addEventListener('online', () => { - // Cancel any pending backoff timer and reconnect immediately. if (this.reconnectTimer) { clearTimeout(this.reconnectTimer) this.reconnectTimer = null } this.reconnectAttempts = 0 - this.reconnectInterval = 1000 this.isReconnecting = false + useWidgetStore().setConnectionFailed(false) if (this.socket) { this.socket.close() } + this.beginRecovery() + this.syncMissedMessages() this.reconnect() }) @@ -232,17 +272,22 @@ export class WidgetWebSocketClient { this.send(joinMessage) } - // Silently refresh conversation list and current conversation to catch messages missed while WS was disconnected. - syncMissedMessages () { + async syncMissedMessages (attempt = 0) { const now = Date.now() - if (now - this.lastSyncAt < 2000) return + if (attempt === 0 && now - this.lastSyncAt < 2000) return this.lastSyncAt = now + clearTimeout(this.syncRetryTimer) const chatStore = useChatStore() - chatStore.fetchConversations(true, true) + const conversationsOk = await chatStore.fetchConversations(true, true) const currentConversationUUID = chatStore.currentConversation?.uuid + let conversationOk = true if (currentConversationUUID) { - chatStore.loadConversation(currentConversationUUID, true, true) + conversationOk = await chatStore.loadConversation(currentConversationUUID, true, true) + } + + if ((!conversationsOk || !conversationOk) && attempt < SYNC_MAX_RETRIES) { + this.syncRetryTimer = setTimeout(() => this.syncMissedMessages(attempt + 1), SYNC_RETRY_DELAY_MS) } } @@ -268,6 +313,12 @@ export class WidgetWebSocketClient { close () { this.manualClose = true this.clearPing() + clearTimeout(this.syncRetryTimer) + clearTimeout(this.connectedBannerTimer) + this.recovering = false + const widgetStore = useWidgetStore() + widgetStore.setConnecting(false) + widgetStore.setConnected(false) if (this.socket) { this.socket.close() } diff --git a/i18n/en-US.json b/i18n/en-US.json index b48e411d..1d55148b 100644 --- a/i18n/en-US.json +++ b/i18n/en-US.json @@ -555,7 +555,9 @@ "globals.messages.caseSensitiveMatch": "Case sensitive match", "globals.messages.close": "Close", "globals.messages.closeChat": "Close chat", + "globals.messages.connected": "Connected", "globals.messages.connecting": "Connecting...", + "globals.messages.connectionFailedRefresh": "Connection failed. Please refresh.", "globals.messages.continueConversation": "Continue your conversation", "globals.messages.copied": "Copied to clipboard", "globals.messages.correctEmailErrors": "Please correct the email errors", From a0851bc70977036871970eae76e7b5b4c0b8d1c9 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Thu, 28 May 2026 17:18:39 +0530 Subject: [PATCH 33/97] fix: show typing indicator on scroll, hide view transition abort errors --- .../main/src/features/conversation/message/MessageList.vue | 2 +- .../main/src/views/conversation/ConversationDetailView.vue | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/apps/main/src/features/conversation/message/MessageList.vue b/frontend/apps/main/src/features/conversation/message/MessageList.vue index 3d44c751..c758b51c 100644 --- a/frontend/apps/main/src/features/conversation/message/MessageList.vue +++ b/frontend/apps/main/src/features/conversation/message/MessageList.vue @@ -156,7 +156,7 @@ watch( watch( () => conversationStore.conversation.isTyping, (isTyping) => { - if (isTyping && !hasUserScrolled.value) scrollToBottom() + if (isTyping && !hasUserScrolled.value) nextTick(scrollToBottom) } ) diff --git a/frontend/apps/main/src/views/conversation/ConversationDetailView.vue b/frontend/apps/main/src/views/conversation/ConversationDetailView.vue index f20fcaac..420320d4 100644 --- a/frontend/apps/main/src/views/conversation/ConversationDetailView.vue +++ b/frontend/apps/main/src/views/conversation/ConversationDetailView.vue @@ -146,10 +146,12 @@ watch( fetchConversation(newUUID) return } - document.startViewTransition(async () => { + const transition = document.startViewTransition(async () => { fetchConversation(newUUID) await nextTick() }) + transition.ready.catch(() => {}) + transition.finished.catch(() => {}) } ) From 5dfefabe52c6be0ae0e2adb0e624c559df0b01e0 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Thu, 28 May 2026 17:39:16 +0530 Subject: [PATCH 34/97] fix: guard previous_conversations loop against nullish current --- .../src/features/conversation/sidebar/PreviousConversations.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/apps/main/src/features/conversation/sidebar/PreviousConversations.vue b/frontend/apps/main/src/features/conversation/sidebar/PreviousConversations.vue index bf48e3c8..f269ceb2 100644 --- a/frontend/apps/main/src/features/conversation/sidebar/PreviousConversations.vue +++ b/frontend/apps/main/src/features/conversation/sidebar/PreviousConversations.vue @@ -10,7 +10,7 @@
Date: Thu, 28 May 2026 19:03:26 +0530 Subject: [PATCH 37/97] fix: extract text from malformed email HTML Swap k3a/html2text for jaytaylor/html2text in stringutil.HTML2Text. k3a returns empty on HTML with orphan tags (e.g. stray ), which made text_content blank for some emails. --- go.mod | 3 +-- go.sum | 12 ------------ internal/stringutil/stringutil.go | 8 ++++++-- 3 files changed, 7 insertions(+), 16 deletions(-) diff --git a/go.mod b/go.mod index 62677c6e..66a8dd07 100644 --- a/go.mod +++ b/go.mod @@ -13,9 +13,9 @@ require ( github.com/gabriel-vasile/mimetype v1.4.11 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/google/uuid v1.6.0 + github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 github.com/jhillyerd/enmime v1.2.0 github.com/jmoiron/sqlx v1.4.0 - github.com/k3a/html2text v1.2.1 github.com/knadh/go-i18n v0.1.0 github.com/knadh/goyesql/v2 v2.2.0 github.com/knadh/koanf/parsers/json v0.1.0 @@ -58,7 +58,6 @@ require ( github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect - github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect diff --git a/go.sum b/go.sum index 4c780e1d..6a2d623a 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,6 @@ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= -github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/schema v1.1.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 h1:iCHtR9CQyktQ5+f3dMVZfwD2KWJUgm7M0gdL9NGr8KA= github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk= @@ -76,10 +74,6 @@ github.com/jhillyerd/enmime v1.2.0/go.mod h1:FRFuUPCLh8PByQv+8xRcLO9QHqaqTqreYho github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= -github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= -github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= -github.com/k3a/html2text v1.2.1 h1:nvnKgBvBR/myqrwfLuiqecUtaK1lB9hGziIJKatNFVY= -github.com/k3a/html2text v1.2.1/go.mod h1:ieEXykM67iT8lTvEWBh6fhpH4B23kB9OMKPdIBmgUqA= github.com/klauspost/compress v1.8.2/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= github.com/klauspost/compress v1.13.4/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= github.com/klauspost/compress v1.15.0/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= @@ -149,10 +143,6 @@ github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/savsgio/gotils v0.0.0-20211223103454-d0aaa54c5899/go.mod h1:oejLrk1Y/5zOF+c/aHtXqn3TFlzzbAgPWg8zBiAHDas= github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 h1:KanIMPX0QdEdB4R3CiimCAbxFrhB3j7h0/OvpYGVQa8= github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg= -github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= -github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= -github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= -github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo= @@ -200,7 +190,6 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -243,7 +232,6 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= diff --git a/internal/stringutil/stringutil.go b/internal/stringutil/stringutil.go index 4cf02e1b..86b6600e 100644 --- a/internal/stringutil/stringutil.go +++ b/internal/stringutil/stringutil.go @@ -10,7 +10,7 @@ import ( "strings" "time" - "github.com/k3a/html2text" + "github.com/jaytaylor/html2text" ) const ( @@ -27,7 +27,11 @@ var ( // HTML2Text converts HTML to text. func HTML2Text(html string) string { - return strings.TrimSpace(html2text.HTML2Text(html)) + out, err := html2text.FromString(html, html2text.Options{TextOnly: true}) + if err != nil { + return "" + } + return strings.TrimSpace(out) } // SanitizeFilename sanitizes the provided filename. From c87266f542ee2da0f206b59568a8cc1befc6f6cf Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Thu, 28 May 2026 19:14:19 +0530 Subject: [PATCH 38/97] Skip ws converstion broadcast when agent is disabled --- internal/conversation/conversation.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index 1e034e97..c32c8078 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -1897,6 +1897,9 @@ func (c *Manager) AuthorizedConnectedAgentIDs(assignedUserID, assignedTeamID nul if err != nil { continue } + if !agent.Enabled { + continue + } if !slices.Contains(agent.Permissions, authzmodels.PermConversationsRead) { continue } From 74e2213e758ba8035c1deb518324de086de4caf7 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Thu, 28 May 2026 19:30:01 +0530 Subject: [PATCH 39/97] fix: auto-resub list when conv uuids change --- frontend/apps/main/src/stores/conversation.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/frontend/apps/main/src/stores/conversation.js b/frontend/apps/main/src/stores/conversation.js index 1b2904a4..1dd3727a 100644 --- a/frontend/apps/main/src/stores/conversation.js +++ b/frontend/apps/main/src/stores/conversation.js @@ -1,5 +1,5 @@ import { defineStore } from 'pinia' -import { computed, reactive, ref, watchEffect } from 'vue' +import { computed, reactive, ref, watch, watchEffect } from 'vue' import { useRouter } from 'vue-router' import { handleHTTPError } from '@shared-ui/utils/http.js' import { TYPING_RECEIVE_TIMEOUT } from '@shared-ui/composables/useTypingIndicator.js' @@ -650,8 +650,6 @@ export const useConversationStore = defineStore('conversation', () => { trimListToCurrentPage() - subscribeListReplace(conversations.data.map(c => c.uuid)) - // Re-check document.hidden in case the user returned while the refresh was in flight. if (pendingNotificationUUIDs.size > 0) { let shouldPlay = false @@ -1088,6 +1086,12 @@ export const useConversationStore = defineStore('conversation', () => { return t('globals.terms.file') } + // On new conversation uuids, subscribere user to those conversations. + watch( + () => conversations.data?.map(c => c.uuid).sort().join(',') ?? '', + () => subscribeListReplace(conversations.data?.map(c => c.uuid) || []) + ) + return { macros, conversations, From 29881f2f827f2ec41539f0edb9170d2431d5b564 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Thu, 28 May 2026 20:07:19 +0530 Subject: [PATCH 40/97] fix: close ws stale-perm gaps and resub on reconnect Reject disabled agents in FilterAuthorizedListUUIDs, kick role members on permission removal or role delete, and re-sub the list and open conv on ws reconnect using local state. Also plug the app logger into the ws package so kicks no longer log when no connections exist. --- cmd/init.go | 2 +- cmd/roles.go | 14 ++++++++++++++ frontend/apps/main/src/websocket.js | 6 +++++- internal/conversation/conversation.go | 3 +++ internal/user/queries.sql | 3 +++ internal/user/user.go | 11 ++++++++++- internal/ws/client.go | 7 +++---- internal/ws/ws.go | 12 +++++++++--- 8 files changed, 48 insertions(+), 10 deletions(-) diff --git a/cmd/init.go b/cmd/init.go index 6b765bdd..cab55db7 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -402,7 +402,7 @@ func initCSAT(db *sqlx.DB, i18n *i18n.I18n) *csat.Manager { // initWS inits websocket hub. func initWS(user *user.Manager) *ws.Hub { - return ws.NewHub(user) + return ws.NewHub(initLogger("ws"), user) } // getCustomStaticDir returns the custom static directory path from CLI flag or config. diff --git a/cmd/roles.go b/cmd/roles.go index 5df2f9f7..be862379 100644 --- a/cmd/roles.go +++ b/cmd/roles.go @@ -43,12 +43,17 @@ func handleDeleteRole(r *fastglue.Request) error { app = r.Context.(*App) id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string)) ) + userIDs, _ := app.user.GetUserIDsByRole(id) if err := app.role.Delete(id); err != nil { return sendErrorEnvelope(r, err) } app.user.InvalidateAllAgentCache() + for _, uid := range userIDs { + app.wsHub.KickUser(uid) + } + return r.SendEnvelope(true) } @@ -96,6 +101,15 @@ func handleUpdateRole(r *fastglue.Request) error { if len(added) > 0 || len(removed) > 0 { app.user.InvalidateAllAgentCache() + if len(removed) > 0 { + userIDs, err := app.user.GetUserIDsByRole(updatedRole.ID) + if err == nil { + for _, id := range userIDs { + app.wsHub.KickUser(id) + } + } + } + if err := app.activityLog.RolePermissionsChanged(auser.ID, auser.Email, ip, updatedRole.ID, updatedRole.Name, added, removed); err != nil { app.lo.Error("error creating activity log", "error", err) } diff --git a/frontend/apps/main/src/websocket.js b/frontend/apps/main/src/websocket.js index 2c234f41..a62a3e90 100644 --- a/frontend/apps/main/src/websocket.js +++ b/frontend/apps/main/src/websocket.js @@ -54,7 +54,11 @@ export class WebSocketClient { this.setupPing() this.flushMessageQueue() if (wasReconnect) { - this.convStore.refreshConversationList() + // RESUB! + const uuids = this.convStore.conversations.data?.map(c => c.uuid) || [] + this.subscribeListReplace(uuids) + const openUUID = this.convStore.conversation.data?.uuid + if (openUUID) this.subscribeToConversation(openUUID) } } diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index c32c8078..1b6688ab 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -1943,6 +1943,9 @@ func (c *Manager) FilterAuthorizedListUUIDs(agentID int, uuids []string) ([]stri if err != nil { return nil, err } + if !user.Enabled { + return nil, nil + } var authorized []string err = c.q.FilterAuthorizedListUUIDs.Select(&authorized, pq.Array(uuids), diff --git a/internal/user/queries.sql b/internal/user/queries.sql index 8c850c40..103b92b7 100644 --- a/internal/user/queries.sql +++ b/internal/user/queries.sql @@ -417,3 +417,6 @@ SELECT (SELECT COUNT(*) FROM transfer_conversations) as conversations_transferred, (SELECT COUNT(*) FROM transfer_messages) as messages_transferred, (SELECT COUNT(*) FROM delete_visitor) as visitor_deleted; + +-- name: get-user-ids-by-role +SELECT user_id FROM user_roles WHERE role_id = $1; diff --git a/internal/user/user.go b/internal/user/user.go index 69ca112b..a46870f1 100644 --- a/internal/user/user.go +++ b/internal/user/user.go @@ -78,6 +78,7 @@ type queries struct { GetUser *sqlx.Stmt `query:"get-user"` GetNotes *sqlx.Stmt `query:"get-notes"` GetNote *sqlx.Stmt `query:"get-note"` + GetUserIDsByRole *sqlx.Stmt `query:"get-user-ids-by-role"` GetUserByExternalID *sqlx.Stmt `query:"get-user-by-external-id"` GetUsersCompact string `query:"get-users-compact"` UpdateContact *sqlx.Stmt `query:"update-contact"` @@ -200,7 +201,6 @@ func (u *Manager) GetContactOrVisitor(id int, email string) (models.User, error) return u.Get(id, email, []string{models.UserTypeContact, models.UserTypeVisitor}) } -// GetSystemUser retrieves the system user. func (u *Manager) GetSystemUser() (models.User, error) { return u.Get(0, models.SystemUserEmail, []string{models.UserTypeAgent}) } @@ -482,6 +482,15 @@ func (u *Manager) MergeVisitorToContact(visitorID, contactID int) error { return nil } +func (u *Manager) GetUserIDsByRole(roleID int) ([]int, error) { + var ids []int + if err := u.q.GetUserIDsByRole.Select(&ids, roleID); err != nil { + u.lo.Error("error fetching user ids by role", "role_id", roleID, "error", err) + return nil, err + } + return ids, nil +} + // ChangeSystemUserPassword updates the system user's password with a newly prompted one. func ChangeSystemUserPassword(ctx context.Context, db *sqlx.DB) error { // Prompt for password and get hashed password diff --git a/internal/ws/client.go b/internal/ws/client.go index e7f63b25..daf72b66 100644 --- a/internal/ws/client.go +++ b/internal/ws/client.go @@ -2,7 +2,6 @@ package ws import ( "encoding/json" - "log" "sync" "time" @@ -93,7 +92,7 @@ func (c *Client) Listen() { func (c *Client) processIncomingMessage(data []byte) { if string(data) == "ping" { if _, err := c.Hub.userStore.UpdateLastActive(c.ID); err != nil { - log.Printf("ws: UpdateLastActive failed for client %d: %v", c.ID, err) + c.Hub.lo.Error("UpdateLastActive failed", "client_id", c.ID, "error", err) } c.SendMessage([]byte("pong"), websocket.TextMessage) return @@ -216,7 +215,7 @@ func (c *Client) SendError(msg string) { select { case c.Send <- models.WSMessage{Data: b, MessageType: websocket.TextMessage}: default: - log.Println("Client send channel is full. Could not send error message.") + c.Hub.lo.Warn("client send channel full, could not send error message", "client_id", c.ID) c.Hub.RemoveClient(c) c.close() } @@ -225,7 +224,7 @@ func (c *Client) SendError(msg string) { // SendMessage sends a message to client. func (c *Client) SendMessage(b []byte, typ byte) { if c.Closed.Get() { - log.Println("Attempted to send message to closed client") + c.Hub.lo.Warn("attempted to send message to closed client", "client_id", c.ID) return } select { diff --git a/internal/ws/ws.go b/internal/ws/ws.go index 458e7792..40be1f96 100644 --- a/internal/ws/ws.go +++ b/internal/ws/ws.go @@ -2,16 +2,18 @@ package ws import ( - "log" "sync" "time" "github.com/abhinavxd/libredesk/internal/ws/models" "github.com/fasthttp/websocket" + "github.com/zerodha/logf" ) // Hub maintains the set of registered websockets clients. type Hub struct { + lo *logf.Logger + clients map[int][]*Client clientsMutex sync.RWMutex @@ -35,8 +37,9 @@ type conversationStore interface { } // NewHub creates a new websocket hub. -func NewHub(userStore userStore) *Hub { +func NewHub(lo *logf.Logger, userStore userStore) *Hub { return &Hub{ + lo: lo, clients: make(map[int][]*Client, 64), clientsMutex: sync.RWMutex{}, convSubsList: make(map[string]map[*Client]struct{}, 1024), @@ -52,7 +55,10 @@ func (h *Hub) KickUser(userID int) { h.clientsMutex.RLock() clients := append([]*Client(nil), h.clients[userID]...) h.clientsMutex.RUnlock() - log.Printf("ws: kicking user %d (%d connections)", userID, len(clients)) + if len(clients) == 0 { + return + } + h.lo.Debug("kicking user ws connections", "user_id", userID, "connections", len(clients)) closeMsg := websocket.FormatCloseMessage(websocket.CloseNormalClosure, "kicked") for _, c := range clients { _ = c.Conn.WriteControl(websocket.CloseMessage, closeMsg, time.Now().Add(time.Second)) From 5538c857496a2451a67bbc81a6fb24398a9134aa Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Thu, 28 May 2026 20:16:21 +0530 Subject: [PATCH 41/97] refactor and dedup code: share conversation read-permission check across authz and ws --- internal/authz/authz.go | 37 ++++++++++++++++----------- internal/authz/authz_test.go | 27 +++++++++++++++++++ internal/conversation/conversation.go | 30 ++-------------------- 3 files changed, 51 insertions(+), 43 deletions(-) diff --git a/internal/authz/authz.go b/internal/authz/authz.go index 767db2b9..14a5c2b8 100644 --- a/internal/authz/authz.go +++ b/internal/authz/authz.go @@ -3,10 +3,12 @@ package authz import ( "slices" + authzmodels "github.com/abhinavxd/libredesk/internal/authz/models" cmodels "github.com/abhinavxd/libredesk/internal/conversation/models" "github.com/abhinavxd/libredesk/internal/envelope" umodels "github.com/abhinavxd/libredesk/internal/user/models" "github.com/knadh/go-i18n" + "github.com/volatiletech/null/v9" "github.com/zerodha/logf" ) @@ -32,28 +34,33 @@ func (e *Enforcer) Enforce(user umodels.User, obj, act string) (bool, error) { // 4. User has the "read_unassigned" permission and the conversation is not assigned to any user or team. // Returns true if access is granted, false otherwise. In case of an error while checking permissions returns false and the error. func (e *Enforcer) EnforceConversationAccess(user umodels.User, conversation cmodels.Conversation) (bool, error) { - if !slices.Contains(user.Permissions, "conversations:read") { - return false, nil + return CanReadAssignment(user, conversation.AssignedUserID, conversation.AssignedTeamID), nil +} + +func CanReadAssignment(user umodels.User, assignedUserID, assignedTeamID null.Int) bool { + if !slices.Contains(user.Permissions, authzmodels.PermConversationsRead) { + return false } - if slices.Contains(user.Permissions, "conversations:read_all") { - return true, nil + if slices.Contains(user.Permissions, authzmodels.PermConversationsReadAll) { + return true } - if conversation.AssignedUserID.Int == user.ID && slices.Contains(user.Permissions, "conversations:read_assigned") { - return true, nil + if assignedUserID.Valid && assignedUserID.Int == user.ID && + slices.Contains(user.Permissions, authzmodels.PermConversationsReadAssigned) { + return true } - if conversation.AssignedTeamID.Int > 0 && slices.Contains(user.Teams.IDs(), conversation.AssignedTeamID.Int) { - if slices.Contains(user.Permissions, "conversations:read_team_all") { - return true, nil + if assignedTeamID.Valid && slices.Contains(user.Teams.IDs(), assignedTeamID.Int) { + if slices.Contains(user.Permissions, authzmodels.PermConversationsReadTeamAll) { + return true } - if conversation.AssignedUserID.Int == 0 && slices.Contains(user.Permissions, "conversations:read_team_inbox") { - return true, nil + if !assignedUserID.Valid && slices.Contains(user.Permissions, authzmodels.PermConversationsReadTeamInbox) { + return true } } - if conversation.AssignedUserID.Int == 0 && conversation.AssignedTeamID.Int == 0 && - slices.Contains(user.Permissions, "conversations:read_unassigned") { - return true, nil + if !assignedUserID.Valid && !assignedTeamID.Valid && + slices.Contains(user.Permissions, authzmodels.PermConversationsReadUnassigned) { + return true } - return false, nil + return false } // EnforceMediaAccess checks read access on the model linked to a media item. diff --git a/internal/authz/authz_test.go b/internal/authz/authz_test.go index a83d4f22..41087adf 100644 --- a/internal/authz/authz_test.go +++ b/internal/authz/authz_test.go @@ -188,6 +188,33 @@ func TestEnforceConversationAccess(t *testing.T) { } } +func TestCanReadAssignment(t *testing.T) { + for _, tt := range convAccessCases() { + t.Run(tt.name, func(t *testing.T) { + got := CanReadAssignment(tt.user, tt.conv.AssignedUserID, tt.conv.AssignedTeamID) + if got != tt.want { + t.Errorf("got %v want %v", got, tt.want) + } + }) + } +} + +func TestCanReadAssignmentMatchesEnforceConversationAccess(t *testing.T) { + e := newTestEnforcer(t) + for _, tt := range convAccessCases() { + t.Run(tt.name, func(t *testing.T) { + helper := CanReadAssignment(tt.user, tt.conv.AssignedUserID, tt.conv.AssignedTeamID) + method, err := e.EnforceConversationAccess(tt.user, tt.conv) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if helper != method { + t.Errorf("helper=%v method=%v (must stay in sync)", helper, method) + } + }) + } +} + func TestEnforceMediaAccess(t *testing.T) { e := newTestEnforcer(t) for _, tt := range mediaAccessCases() { diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index 1b6688ab..ee90c34d 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -15,6 +15,7 @@ import ( "sync" "time" + "github.com/abhinavxd/libredesk/internal/authz" authzmodels "github.com/abhinavxd/libredesk/internal/authz/models" "github.com/abhinavxd/libredesk/internal/automation" amodels "github.com/abhinavxd/libredesk/internal/automation/models" @@ -1900,35 +1901,8 @@ func (c *Manager) AuthorizedConnectedAgentIDs(assignedUserID, assignedTeamID nul if !agent.Enabled { continue } - if !slices.Contains(agent.Permissions, authzmodels.PermConversationsRead) { - continue - } - if slices.Contains(agent.Permissions, authzmodels.PermConversationsReadAll) { + if authz.CanReadAssignment(agent, assignedUserID, assignedTeamID) { out = append(out, id) - continue - } - if slices.Contains(agent.Permissions, authzmodels.PermConversationsReadAssigned) && - assignedUserID.Valid && assignedUserID.Int == agent.ID { - out = append(out, id) - continue - } - if assignedTeamID.Valid { - tid := assignedTeamID.Int - if slices.Contains(agent.Permissions, authzmodels.PermConversationsReadTeamAll) && - slices.Contains(agent.Teams.IDs(), tid) { - out = append(out, id) - continue - } - if slices.Contains(agent.Permissions, authzmodels.PermConversationsReadTeamInbox) && - slices.Contains(agent.Teams.IDs(), tid) && !assignedUserID.Valid { - out = append(out, id) - continue - } - } - if slices.Contains(agent.Permissions, authzmodels.PermConversationsReadUnassigned) && - !assignedUserID.Valid && !assignedTeamID.Valid { - out = append(out, id) - continue } } return out From bba99d45e5872ff1d2b31d6720f84d8dc2d4bfe9 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Thu, 28 May 2026 17:18:39 +0530 Subject: [PATCH 42/97] fix: show typing indicator on scroll, hide view transition abort errors --- .../main/src/features/conversation/message/MessageList.vue | 2 +- .../main/src/views/conversation/ConversationDetailView.vue | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/apps/main/src/features/conversation/message/MessageList.vue b/frontend/apps/main/src/features/conversation/message/MessageList.vue index 3d44c751..c758b51c 100644 --- a/frontend/apps/main/src/features/conversation/message/MessageList.vue +++ b/frontend/apps/main/src/features/conversation/message/MessageList.vue @@ -156,7 +156,7 @@ watch( watch( () => conversationStore.conversation.isTyping, (isTyping) => { - if (isTyping && !hasUserScrolled.value) scrollToBottom() + if (isTyping && !hasUserScrolled.value) nextTick(scrollToBottom) } ) diff --git a/frontend/apps/main/src/views/conversation/ConversationDetailView.vue b/frontend/apps/main/src/views/conversation/ConversationDetailView.vue index f20fcaac..420320d4 100644 --- a/frontend/apps/main/src/views/conversation/ConversationDetailView.vue +++ b/frontend/apps/main/src/views/conversation/ConversationDetailView.vue @@ -146,10 +146,12 @@ watch( fetchConversation(newUUID) return } - document.startViewTransition(async () => { + const transition = document.startViewTransition(async () => { fetchConversation(newUUID) await nextTick() }) + transition.ready.catch(() => {}) + transition.finished.catch(() => {}) } ) From 9159c26d5f9953b216340eb9c9eb599f2c7ccf90 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Thu, 28 May 2026 20:40:54 +0530 Subject: [PATCH 43/97] fix: clean up widget ws state on close and announce banner to screen readers --- frontend/apps/widget/src/components/BaseBanner.vue | 8 +++++++- frontend/apps/widget/src/websocket.js | 10 ++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/frontend/apps/widget/src/components/BaseBanner.vue b/frontend/apps/widget/src/components/BaseBanner.vue index a06e48f3..2d08ad8d 100644 --- a/frontend/apps/widget/src/components/BaseBanner.vue +++ b/frontend/apps/widget/src/components/BaseBanner.vue @@ -6,7 +6,13 @@ defineProps({ @@ -103,8 +85,9 @@ import ActivityMessageBubble from './ActivityMessageBubble.vue' import { useConversationStore } from '@main/stores/conversation' import { useUserStore } from '@main/stores/user' import { Button } from '@shared-ui/components/ui/button' -import { RefreshCw, Loader2, X } from 'lucide-vue-next' +import { RefreshCw, Loader2 } from 'lucide-vue-next' import ScrollToBottomButton from '@shared-ui/components/ScrollToBottomButton' +import AssignSelfNudge from './AssignSelfNudge.vue' import { useEmitter } from '@main/composables/useEmitter' import { EMITTER_EVENTS } from '@main/constants/emitterEvents' import MessagesSkeleton from './MessagesSkeleton.vue' @@ -129,10 +112,7 @@ let currentConversationUUID = '' let openScrollDone = false const assignToSelf = () => { - const id = userStore.userID - conversationStore.current.assigned_user_id = id - conversationStore.updateAssignee('user', { assignee_id: id }) - showAssignNudge.value = false + conversationStore.updateAssignee('user', { assignee_id: userStore.userID }) } const { hasUserScrolled, scrollToBottom, scrollToOffset, handleScroll } = useStickyScroll(threadEl, contentEl, { diff --git a/frontend/apps/main/src/stores/conversation.js b/frontend/apps/main/src/stores/conversation.js index ba789803..f405f413 100644 --- a/frontend/apps/main/src/stores/conversation.js +++ b/frontend/apps/main/src/stores/conversation.js @@ -743,6 +743,7 @@ export const useConversationStore = defineStore('conversation', () => { async function updateAssignee (type, v) { try { await api.updateAssignee(conversation.data.uuid, type, v) + conversation.data[`assigned_${type}_id`] = v.assignee_id } catch (error) { emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { variant: 'destructive', From 2d81703ae28286a7e5484653cefc7b5612a2be41 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Sun, 7 Jun 2026 15:17:03 +0530 Subject: [PATCH 77/97] fix: address review nits on transcript download - sanitize Content-Disposition filename via stringutil.SanitizeFilename to neutralize header injection - use i18n.Tc for count-aware singular/plural attachment label - append download anchor to DOM and defer URL.revokeObjectURL for cross-browser reliability --- cmd/conversation.go | 3 ++- frontend/apps/main/src/features/conversation/Conversation.vue | 4 +++- internal/conversation/transcript.go | 2 +- internal/conversation/transcript_test.go | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/cmd/conversation.go b/cmd/conversation.go index 62f9a2c7..1e8f014f 100644 --- a/cmd/conversation.go +++ b/cmd/conversation.go @@ -354,7 +354,8 @@ func handleDownloadConversationTranscript(r *fastglue.Request) error { } transcript := app.conversation.BuildTranscript(*conversation, messages, time.Now()) - r.RequestCtx.Response.Header.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="transcript-%s.txt"`, conversation.ReferenceNumber)) + filename := stringutil.SanitizeFilename(fmt.Sprintf("transcript-%s.txt", conversation.ReferenceNumber)) + r.RequestCtx.Response.Header.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) r.RequestCtx.Response.Header.Set("X-Content-Type-Options", "nosniff") r.RequestCtx.SetContentType("text/plain; charset=utf-8") r.RequestCtx.SetBody(transcript) diff --git a/frontend/apps/main/src/features/conversation/Conversation.vue b/frontend/apps/main/src/features/conversation/Conversation.vue index 057c5ea2..15eb188d 100644 --- a/frontend/apps/main/src/features/conversation/Conversation.vue +++ b/frontend/apps/main/src/features/conversation/Conversation.vue @@ -81,8 +81,10 @@ const downloadTranscript = async () => { const link = document.createElement('a') link.href = url link.download = `transcript-${conversation.reference_number}.txt` + document.body.appendChild(link) link.click() - URL.revokeObjectURL(url) + link.remove() + setTimeout(() => URL.revokeObjectURL(url), 0) } catch (error) { if (error.response?.data instanceof Blob) { try { diff --git a/internal/conversation/transcript.go b/internal/conversation/transcript.go index 857d7fbf..2a7a6618 100644 --- a/internal/conversation/transcript.go +++ b/internal/conversation/transcript.go @@ -43,7 +43,7 @@ func (m *Manager) BuildTranscript(conversation models.Conversation, messages []m for _, attachment := range message.Attachments { names = append(names, attachment.Name) } - fmt.Fprintf(&b, "%s: %s\n", m.i18n.P("globals.terms.attachment"), strings.Join(names, ", ")) + fmt.Fprintf(&b, "%s: %s\n", m.i18n.Tc("globals.terms.attachment", len(message.Attachments)), strings.Join(names, ", ")) } } return []byte(b.String()) diff --git a/internal/conversation/transcript_test.go b/internal/conversation/transcript_test.go index b41f1b4b..04665e33 100644 --- a/internal/conversation/transcript_test.go +++ b/internal/conversation/transcript_test.go @@ -75,7 +75,7 @@ func TestBuildTranscript(t *testing.T) { "[May 11, 2026 10:01 AM UTC] John Doe (Contact):", "Hi, I need a refund for order #555", "[May 11, 2026 10:05 AM UTC] Priya (Agent):", - "Attachments: invoice.pdf", + "Attachment: invoice.pdf", "Falls back to HTML", } for _, want := range wantContains { From f70e4886623a43ef156257444bf9e4e54cd64f94 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Sun, 7 Jun 2026 15:22:52 +0530 Subject: [PATCH 78/97] refactor: sanitize only the reference number for transcript filename --- cmd/conversation.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/conversation.go b/cmd/conversation.go index 1e8f014f..453699cc 100644 --- a/cmd/conversation.go +++ b/cmd/conversation.go @@ -354,7 +354,8 @@ func handleDownloadConversationTranscript(r *fastglue.Request) error { } transcript := app.conversation.BuildTranscript(*conversation, messages, time.Now()) - filename := stringutil.SanitizeFilename(fmt.Sprintf("transcript-%s.txt", conversation.ReferenceNumber)) + safeRef := stringutil.SanitizeFilename(conversation.ReferenceNumber) + filename := fmt.Sprintf("transcript-%s.txt", safeRef) r.RequestCtx.Response.Header.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) r.RequestCtx.Response.Header.Set("X-Content-Type-Options", "nosniff") r.RequestCtx.SetContentType("text/plain; charset=utf-8") From ae328d3c5116fbafc80684873922bdd4fde7c09e Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Tue, 9 Jun 2026 01:12:09 +0530 Subject: [PATCH 79/97] Update ROADMAP.md --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0312396d..238cf4dc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,7 +6,7 @@ The open-source customer support platform you actually own. One inbox for email, - WhatsApp channel - WIP - Conversation transcript download - WIP - Extend view filters (https://github.com/abhinavxd/libredesk/issues/344) - WIP -- Contact merging - WIP +- Contact merging - TODO - Deletable private notes - TODO - Help center with articles - TODO - AI assistants - TODO From d300ce26e6fe04ee0f1fd1ff8e922978b6acc046 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Wed, 10 Jun 2026 21:20:07 +0530 Subject: [PATCH 80/97] feat: show day separators in conversation message list closes https://github.com/abhinavxd/libredesk/issues/376 --- .../conversation/message/MessageList.vue | 12 +++++++- .../components/DaySeparator/DaySeparator.vue | 29 +++++++++++++++++++ .../components/DaySeparator/index.js | 1 + i18n/en-US.json | 1 + 4 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 frontend/shared-ui/components/DaySeparator/DaySeparator.vue create mode 100644 frontend/shared-ui/components/DaySeparator/index.js diff --git a/frontend/apps/main/src/features/conversation/message/MessageList.vue b/frontend/apps/main/src/features/conversation/message/MessageList.vue index 5d425719..274cda8d 100644 --- a/frontend/apps/main/src/features/conversation/message/MessageList.vue +++ b/frontend/apps/main/src/features/conversation/message/MessageList.vue @@ -32,6 +32,11 @@ :data-message-uuid="row.message.uuid" :class="[row.spacingClass, { 'my-2': row.message.type === 'activity' }]" > +
{ message, groupWithPrev, groupWithNext, - spacingClass: getSpacingClass(index, groupWithPrev) + spacingClass: getSpacingClass(index, groupWithPrev), + showDaySeparator: + index === 0 || + !isSameDay(new Date(messages[index - 1].created_at), new Date(message.created_at)) } }) }) diff --git a/frontend/shared-ui/components/DaySeparator/DaySeparator.vue b/frontend/shared-ui/components/DaySeparator/DaySeparator.vue new file mode 100644 index 00000000..c56edbe9 --- /dev/null +++ b/frontend/shared-ui/components/DaySeparator/DaySeparator.vue @@ -0,0 +1,29 @@ + + + diff --git a/frontend/shared-ui/components/DaySeparator/index.js b/frontend/shared-ui/components/DaySeparator/index.js new file mode 100644 index 00000000..70bf7860 --- /dev/null +++ b/frontend/shared-ui/components/DaySeparator/index.js @@ -0,0 +1 @@ +export { default } from './DaySeparator.vue' diff --git a/i18n/en-US.json b/i18n/en-US.json index f2aa1de0..66d41c79 100644 --- a/i18n/en-US.json +++ b/i18n/en-US.json @@ -864,6 +864,7 @@ "globals.terms.week": "Week | Weeks", "globals.terms.white": "White", "globals.terms.workspace": "Workspace", + "globals.terms.yesterday": "Yesterday", "globals.terms.you": "You", "imageLightbox.next": "Next image", "imageLightbox.previous": "Previous image", From ef1ef15adfe7d1e882b83f42b2b0252f1c6a403d Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Sat, 13 Jun 2026 04:42:18 +0530 Subject: [PATCH 81/97] Update roadmap with new features and remove WIP items --- ROADMAP.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 238cf4dc..6cd46449 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,8 +4,7 @@ The open-source customer support platform you actually own. One inbox for email, ## Near Term - WhatsApp channel - WIP -- Conversation transcript download - WIP -- Extend view filters (https://github.com/abhinavxd/libredesk/issues/344) - WIP +- Telegram channel - WIP - Contact merging - TODO - Deletable private notes - TODO - Help center with articles - TODO From 1b87433de472039acce2a04a2785703ef373234f Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Tue, 16 Jun 2026 12:41:28 +0530 Subject: [PATCH 82/97] fixes autoassigner overriding a manual assignment The autoassigner fetched the unassigned list once, then looped and assigned each one. If the conversation was reassigned while that slow batch was still running, the autoassigner's write still landed and overrode it, because the UPDATE had no guard. Adds an atomic claim that only assigns when the conversation is still unassigned AND still in the team the user was picked from. A manual user assignment or a team change between the list read and the claim now makes the claim a no-op, and the side-effects (webhook, automation, notification) are skipped. Manual assignment keeps its unconditional override path. --- internal/autoassigner/autoassigner.go | 18 ++++++++---- internal/conversation/conversation.go | 41 +++++++++++++++++++++++---- internal/conversation/queries.sql | 6 ++++ 3 files changed, 53 insertions(+), 12 deletions(-) diff --git a/internal/autoassigner/autoassigner.go b/internal/autoassigner/autoassigner.go index 7f7561e1..70e5e10e 100644 --- a/internal/autoassigner/autoassigner.go +++ b/internal/autoassigner/autoassigner.go @@ -10,6 +10,7 @@ import ( "sync" "time" + "github.com/abhinavxd/libredesk/internal/conversation" "github.com/abhinavxd/libredesk/internal/conversation/models" tmodels "github.com/abhinavxd/libredesk/internal/team/models" umodels "github.com/abhinavxd/libredesk/internal/user/models" @@ -28,7 +29,7 @@ const ( type conversationStore interface { GetUnassignedConversations() ([]models.Conversation, error) - UpdateConversationUserAssignee(conversationUUID string, userID int, user umodels.User) error + AssignUnassignedConversationUser(conversationUUID string, userID, expectedTeamID int, user umodels.User) error ActiveUserConversationsCount(userID int) (int, error) } @@ -204,8 +205,8 @@ func (e *Engine) assignConversations() error { e.lo.Debug("found unassigned conversations", "count", len(unassignedConversations)) } - for _, conversation := range unassignedConversations { - teamID := conversation.AssignedTeamID.Int + for _, conv := range unassignedConversations { + teamID := conv.AssignedTeamID.Int teamMax := e.teamMaxAutoAssignments[teamID] poolSize := e.poolSize(teamID) @@ -215,7 +216,7 @@ func (e *Engine) assignConversations() error { if err != nil { // Log other errors. if err != ErrTeamNotFound && err != ErrNoUsersInPool { - e.lo.Error("error fetching user from balancer pool", "conversation_uuid", conversation.UUID, "error", err) + e.lo.Error("error fetching user from balancer pool", "conversation_uuid", conv.UUID, "error", err) } break } @@ -238,8 +239,13 @@ func (e *Engine) assignConversations() error { continue } - if err := e.conversationStore.UpdateConversationUserAssignee(conversation.UUID, userID, e.systemUser); err != nil { - e.lo.Error("error assigning conversation", "conversation_uuid", conversation.UUID, "user_id", userID, "error", err) + if err := e.conversationStore.AssignUnassignedConversationUser(conv.UUID, userID, teamID, e.systemUser); err != nil { + // Already assigned by someone else, stop trying. + if errors.Is(err, conversation.ErrConversationAlreadyAssigned) { + e.lo.Debug("conversation already assigned, skipping", "conversation_uuid", conv.UUID) + break + } + e.lo.Error("error assigning conversation", "conversation_uuid", conv.UUID, "user_id", userID, "error", err) continue } break diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index 965fc605..2829b161 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -50,6 +50,7 @@ var ( //go:embed queries.sql efs embed.FS errConversationNotFound = errors.New("conversation not found") + ErrConversationAlreadyAssigned = errors.New("conversation already assigned") conversationsAllowedFields = []string{"status_id", "priority_id", "assigned_team_id", "assigned_user_id", "inbox_id", "last_message_at", "last_interaction_at", "created_at", "waiting_since", "next_sla_deadline_at", "priority_id"} conversationStatusAllowedFields = []string{"id", "name"} usersAllowedFields = []string{"email"} @@ -277,6 +278,7 @@ type queries struct { UpsertUserLastSeen *sqlx.Stmt `query:"upsert-user-last-seen"` MarkConversationUnread *sqlx.Stmt `query:"mark-conversation-unread"` UpdateConversationAssignedUser *sqlx.Stmt `query:"update-conversation-assigned-user"` + ClaimUnassignedConversation *sqlx.Stmt `query:"claim-unassigned-conversation"` UpdateConversationAssignedTeam *sqlx.Stmt `query:"update-conversation-assigned-team"` UpdateConversationCustomAttributes *sqlx.Stmt `query:"update-conversation-custom-attributes"` UpdateConversationPriority *sqlx.Stmt `query:"update-conversation-priority"` @@ -681,8 +683,33 @@ func (c *Manager) UpdateConversationUserAssignee(uuid string, assigneeID int, ac if err := c.UpdateAssignee(uuid, assigneeID, models.AssigneeTypeUser); err != nil { return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil) } + return c.afterUserAssigned(uuid, assigneeID, actor) +} - // Refetch the conversation to get the updated details. +// AssignUnassignedConversationUser atomically assigns a conversation only if still unassigned and still in expectedTeamID, else returns ErrConversationAlreadyAssigned. +func (c *Manager) AssignUnassignedConversationUser(uuid string, assigneeID, expectedTeamID int, actor umodels.User) error { + prev, prevErr := c.GetConversationListItem(uuid) + + res, err := c.q.ClaimUnassignedConversation.Exec(uuid, assigneeID, expectedTeamID) + if err != nil { + c.lo.Error("error claiming unassigned conversation", "uuid", uuid, "error", err) + return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil) + } + rows, err := res.RowsAffected() + if err != nil { + c.lo.Error("error reading rows affected for conversation claim", "uuid", uuid, "error", err) + return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil) + } + if rows == 0 { + return ErrConversationAlreadyAssigned + } + + c.broadcastReassignment(uuid, prev, prevErr) + return c.afterUserAssigned(uuid, assigneeID, actor) +} + +// afterUserAssigned runs the side-effects shared by every user-assignment path. +func (c *Manager) afterUserAssigned(uuid string, assigneeID int, actor umodels.User) error { conversation, err := c.GetConversation(0, uuid, "") if err != nil { return err @@ -695,10 +722,8 @@ func (c *Manager) UpdateConversationUserAssignee(uuid string, assigneeID int, ac "conversation": conversation, }) - // Evaluate automation rules. c.automation.EvaluateConversationUpdateRules(conversation, amodels.EventConversationUserAssigned) - // Send notifications to assignee (skip if self-assigning). if assigneeID != actor.ID { if err := c.NotifyAssignment([]int{assigneeID}, conversation); err != nil { c.lo.Error("error sending assignment notification", "error", err) @@ -709,7 +734,6 @@ func (c *Manager) UpdateConversationUserAssignee(uuid string, assigneeID int, ac return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil) } - // Broadcast conversation update to widget clients with assignee info. agent, err := c.userStore.GetAgent(assigneeID, "") if err == nil { c.SignAvatarURL(&agent.AvatarURL) @@ -799,17 +823,22 @@ func (c *Manager) UpdateAssignee(uuid string, assigneeID int, assigneeType strin default: return fmt.Errorf("invalid assignee type: %s", assigneeType) } + c.broadcastReassignment(uuid, prev, prevErr) + return nil +} + +// broadcastReassignment broadcasts a reassignment to agents, given the conversation's prior list item. +func (c *Manager) broadcastReassignment(uuid string, prev models.ConversationListItem, prevErr error) { next, nextErr := c.GetConversationListItem(uuid) if nextErr != nil { c.lo.Error("error fetching conversation list item for assignee broadcast", "uuid", uuid, "error", nextErr) - return nil + return } var prevPtr *models.ConversationListItem if prevErr == nil { prevPtr = &prev } c.BroadcastConvReassignment(prevPtr, &next) - return nil } // UpdateConversationPriority updates the priority of a conversation. diff --git a/internal/conversation/queries.sql b/internal/conversation/queries.sql index 6fd5c722..a7182f1b 100644 --- a/internal/conversation/queries.sql +++ b/internal/conversation/queries.sql @@ -391,6 +391,12 @@ SET assigned_user_id = $2, updated_at = NOW() WHERE uuid = $1; +-- name: claim-unassigned-conversation +UPDATE conversations +SET assigned_user_id = $2, +updated_at = NOW() +WHERE uuid = $1 AND assigned_user_id IS NULL AND assigned_team_id = $3; + -- name: update-conversation-contact-last-seen UPDATE conversations SET contact_last_seen_at = NOW(), From 18ae7b95d66e582b02d7bcb313da923751acf8dd Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Tue, 16 Jun 2026 15:58:42 +0530 Subject: [PATCH 83/97] make updateAssignee private so user assignment can't skip hooks --- internal/conversation/conversation.go | 46 +++++++++++++-------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index 2829b161..6c122285 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -680,7 +680,7 @@ func (c *Manager) UpdateConversationWaitingSince(conversationUUID string, at *ti // UpdateConversationUserAssignee sets the assignee of a conversation to a specifc user. func (c *Manager) UpdateConversationUserAssignee(uuid string, assigneeID int, actor umodels.User) error { - if err := c.UpdateAssignee(uuid, assigneeID, models.AssigneeTypeUser); err != nil { + if err := c.updateAssignee(uuid, assigneeID, models.AssigneeTypeUser); err != nil { return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil) } return c.afterUserAssigned(uuid, assigneeID, actor) @@ -760,7 +760,7 @@ func (c *Manager) UpdateConversationTeamAssignee(uuid string, teamID int, actor } previousAssignedTeamID := conversation.AssignedTeamID.Int - if err := c.UpdateAssignee(uuid, teamID, models.AssigneeTypeTeam); err != nil { + if err := c.updateAssignee(uuid, teamID, models.AssigneeTypeTeam); err != nil { return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil) } @@ -806,27 +806,6 @@ func (c *Manager) UpdateConversationTeamAssignee(uuid string, teamID int, actor return nil } -// UpdateAssignee updates the assignee of a conversation. -func (c *Manager) UpdateAssignee(uuid string, assigneeID int, assigneeType string) error { - prev, prevErr := c.GetConversationListItem(uuid) - switch assigneeType { - case models.AssigneeTypeUser: - if _, err := c.q.UpdateConversationAssignedUser.Exec(uuid, assigneeID); err != nil { - c.lo.Error("error updating conversation assignee", "error", err) - return fmt.Errorf("updating assignee: %w", err) - } - case models.AssigneeTypeTeam: - if _, err := c.q.UpdateConversationAssignedTeam.Exec(uuid, assigneeID); err != nil { - c.lo.Error("error updating conversation assignee", "error", err) - return fmt.Errorf("updating assignee: %w", err) - } - default: - return fmt.Errorf("invalid assignee type: %s", assigneeType) - } - c.broadcastReassignment(uuid, prev, prevErr) - return nil -} - // broadcastReassignment broadcasts a reassignment to agents, given the conversation's prior list item. func (c *Manager) broadcastReassignment(uuid string, prev models.ConversationListItem, prevErr error) { next, nextErr := c.GetConversationListItem(uuid) @@ -1970,6 +1949,27 @@ func (c *Manager) FilterAuthorizedListUUIDs(agentID int, uuids []string) ([]stri return authorized, nil } +// updateAssignee updates the assignee of a conversation. +func (c *Manager) updateAssignee(uuid string, assigneeID int, assigneeType string) error { + prev, prevErr := c.GetConversationListItem(uuid) + switch assigneeType { + case models.AssigneeTypeUser: + if _, err := c.q.UpdateConversationAssignedUser.Exec(uuid, assigneeID); err != nil { + c.lo.Error("error updating conversation assignee", "error", err) + return fmt.Errorf("updating assignee: %w", err) + } + case models.AssigneeTypeTeam: + if _, err := c.q.UpdateConversationAssignedTeam.Exec(uuid, assigneeID); err != nil { + c.lo.Error("error updating conversation assignee", "error", err) + return fmt.Errorf("updating assignee: %w", err) + } + default: + return fmt.Errorf("invalid assignee type: %s", assigneeType) + } + c.broadcastReassignment(uuid, prev, prevErr) + return nil +} + func nullTimeOrNil(t null.Time) any { if !t.Valid || t.Time.IsZero() { return nil From 15e5c35d37ee69b60f5c833f2c608d7b9f4e4c3d Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Tue, 16 Jun 2026 16:04:24 +0530 Subject: [PATCH 84/97] renames conversation assignment funcs for clearer intent --- internal/autoassigner/autoassigner.go | 4 ++-- internal/conversation/conversation.go | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/internal/autoassigner/autoassigner.go b/internal/autoassigner/autoassigner.go index 70e5e10e..cce34be9 100644 --- a/internal/autoassigner/autoassigner.go +++ b/internal/autoassigner/autoassigner.go @@ -29,7 +29,7 @@ const ( type conversationStore interface { GetUnassignedConversations() ([]models.Conversation, error) - AssignUnassignedConversationUser(conversationUUID string, userID, expectedTeamID int, user umodels.User) error + ClaimUnassignedConversation(conversationUUID string, userID, expectedTeamID int, user umodels.User) error ActiveUserConversationsCount(userID int) (int, error) } @@ -239,7 +239,7 @@ func (e *Engine) assignConversations() error { continue } - if err := e.conversationStore.AssignUnassignedConversationUser(conv.UUID, userID, teamID, e.systemUser); err != nil { + if err := e.conversationStore.ClaimUnassignedConversation(conv.UUID, userID, teamID, e.systemUser); err != nil { // Already assigned by someone else, stop trying. if errors.Is(err, conversation.ErrConversationAlreadyAssigned) { e.lo.Debug("conversation already assigned, skipping", "conversation_uuid", conv.UUID) diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index 6c122285..7526a613 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -683,11 +683,11 @@ func (c *Manager) UpdateConversationUserAssignee(uuid string, assigneeID int, ac if err := c.updateAssignee(uuid, assigneeID, models.AssigneeTypeUser); err != nil { return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil) } - return c.afterUserAssigned(uuid, assigneeID, actor) + return c.afterUserAssignedHooks(uuid, assigneeID, actor) } -// AssignUnassignedConversationUser atomically assigns a conversation only if still unassigned and still in expectedTeamID, else returns ErrConversationAlreadyAssigned. -func (c *Manager) AssignUnassignedConversationUser(uuid string, assigneeID, expectedTeamID int, actor umodels.User) error { +// ClaimUnassignedConversation atomically assigns a conversation only if still unassigned and still in expectedTeamID, else returns ErrConversationAlreadyAssigned. +func (c *Manager) ClaimUnassignedConversation(uuid string, assigneeID, expectedTeamID int, actor umodels.User) error { prev, prevErr := c.GetConversationListItem(uuid) res, err := c.q.ClaimUnassignedConversation.Exec(uuid, assigneeID, expectedTeamID) @@ -705,11 +705,11 @@ func (c *Manager) AssignUnassignedConversationUser(uuid string, assigneeID, expe } c.broadcastReassignment(uuid, prev, prevErr) - return c.afterUserAssigned(uuid, assigneeID, actor) + return c.afterUserAssignedHooks(uuid, assigneeID, actor) } -// afterUserAssigned runs the side-effects shared by every user-assignment path. -func (c *Manager) afterUserAssigned(uuid string, assigneeID int, actor umodels.User) error { +// afterUserAssignedHooks runs the side-effects shared by every user-assignment path. +func (c *Manager) afterUserAssignedHooks(uuid string, assigneeID int, actor umodels.User) error { conversation, err := c.GetConversation(0, uuid, "") if err != nil { return err @@ -1949,7 +1949,7 @@ func (c *Manager) FilterAuthorizedListUUIDs(agentID int, uuids []string) ([]stri return authorized, nil } -// updateAssignee updates the assignee of a conversation. +// updateAssignee updates the team / user assignee of a conversation and broadcasts the reassignment to connected clients. func (c *Manager) updateAssignee(uuid string, assigneeID int, assigneeType string) error { prev, prevErr := c.GetConversationListItem(uuid) switch assigneeType { From 3247a3aa58acf40f986721a1b5c266eaa2900537 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Fri, 5 Jun 2026 14:44:52 +0530 Subject: [PATCH 85/97] feat: support nested AND/OR filter groups and new fields in views --- cmd/views.go | 9 + .../src/components/filter/ConnectorToggle.vue | 22 ++ .../components/filter/FilterGroupBuilder.vue | 49 +++ .../src/components/filter/FilterGroupCard.vue | 48 +++ .../main/src/components/filter/FilterRow.vue | 175 +++++++++ .../main/src/components/filter/filterTree.js | 102 +++++ .../src/composables/useConversationFilters.js | 62 +++ .../apps/main/src/constants/filterConfig.js | 16 + .../src/features/admin/agents/AgentForm.vue | 6 +- .../src/features/admin/automation/RuleBox.vue | 3 +- .../admin/shared-views/SharedViewForm.vue | 110 ++---- .../apps/main/src/features/view/ViewForm.vue | 121 ++---- i18n/en-US.json | 14 +- internal/activity_log/activity_log.go | 2 +- internal/conversation/conversation.go | 133 +++---- internal/dbutil/builder.go | 357 ++++++++++++------ internal/dbutil/builder_test.go | 136 +++++++ internal/user/user.go | 2 +- 18 files changed, 1009 insertions(+), 358 deletions(-) create mode 100644 frontend/apps/main/src/components/filter/ConnectorToggle.vue create mode 100644 frontend/apps/main/src/components/filter/FilterGroupBuilder.vue create mode 100644 frontend/apps/main/src/components/filter/FilterGroupCard.vue create mode 100644 frontend/apps/main/src/components/filter/FilterRow.vue create mode 100644 frontend/apps/main/src/components/filter/filterTree.js create mode 100644 internal/dbutil/builder_test.go diff --git a/cmd/views.go b/cmd/views.go index cfb8d5b9..1c945694 100644 --- a/cmd/views.go +++ b/cmd/views.go @@ -47,6 +47,9 @@ func handleCreateUserView(r *fastglue.Request) error { if string(view.Filters) == "" { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`Filters`"), nil, envelope.InputError) } + if err := app.conversation.ValidateListFilters(string(view.Filters)); err != nil { + return sendErrorEnvelope(r, err) + } createdView, err := app.view.Create(view.Name, view.Filters, user.ID) if err != nil { return sendErrorEnvelope(r, err) @@ -106,6 +109,9 @@ func handleUpdateUserView(r *fastglue.Request) error { if string(view.Filters) == "" { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`filters`"), nil, envelope.InputError) } + if err := app.conversation.ValidateListFilters(string(view.Filters)); err != nil { + return sendErrorEnvelope(r, err) + } v, err := app.view.Get(id) if err != nil { return sendErrorEnvelope(r, err) @@ -264,6 +270,9 @@ func validateSharedView(app *App, view vmodels.View) error { if string(view.Filters) == "" { return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`filters`"), nil) } + if err := app.conversation.ValidateListFilters(string(view.Filters)); err != nil { + return err + } if view.Visibility != vmodels.VisibilityAll && view.Visibility != vmodels.VisibilityTeam { return envelope.NewError(envelope.InputError, app.i18n.T("globals.messages.somethingWentWrong"), nil) } diff --git a/frontend/apps/main/src/components/filter/ConnectorToggle.vue b/frontend/apps/main/src/components/filter/ConnectorToggle.vue new file mode 100644 index 00000000..fbdcc9cf --- /dev/null +++ b/frontend/apps/main/src/components/filter/ConnectorToggle.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/apps/main/src/components/filter/FilterGroupBuilder.vue b/frontend/apps/main/src/components/filter/FilterGroupBuilder.vue new file mode 100644 index 00000000..e4ee6a08 --- /dev/null +++ b/frontend/apps/main/src/components/filter/FilterGroupBuilder.vue @@ -0,0 +1,49 @@ + + + diff --git a/frontend/apps/main/src/components/filter/FilterGroupCard.vue b/frontend/apps/main/src/components/filter/FilterGroupCard.vue new file mode 100644 index 00000000..213a5c70 --- /dev/null +++ b/frontend/apps/main/src/components/filter/FilterGroupCard.vue @@ -0,0 +1,48 @@ + + + diff --git a/frontend/apps/main/src/components/filter/FilterRow.vue b/frontend/apps/main/src/components/filter/FilterRow.vue new file mode 100644 index 00000000..ee0897e4 --- /dev/null +++ b/frontend/apps/main/src/components/filter/FilterRow.vue @@ -0,0 +1,175 @@ + + + diff --git a/frontend/apps/main/src/components/filter/filterTree.js b/frontend/apps/main/src/components/filter/filterTree.js new file mode 100644 index 00000000..5f8d7748 --- /dev/null +++ b/frontend/apps/main/src/components/filter/filterTree.js @@ -0,0 +1,102 @@ +import { FIELD_TYPE, LOGIC, OPERATOR } from '@/constants/filterConfig' + +let _seq = 0 +const uid = () => `f${++_seq}` + +export const createLeaf = () => ({ __id: uid(), model: '', field: '', operator: '', value: '' }) + +export const createGroup = (logic = LOGIC.AND) => ({ __id: uid(), logic, rules: [createLeaf()] }) + +export const createRoot = (logic = LOGIC.AND) => ({ __id: uid(), logic, rules: [createGroup()] }) + +export const isGroupNode = (node) => + !!node && (Array.isArray(node.rules) || typeof node.logic === 'string') + +export const collectLeaves = (node) => + isGroupNode(node) ? (node.rules || []).flatMap(collectLeaves) : [node] + +export const isPartialLeaf = (leaf) => + !leaf.field || + !leaf.operator || + (![OPERATOR.SET, OPERATOR.NOT_SET].includes(leaf.operator) && + (leaf.value === undefined || + leaf.value === null || + leaf.value === '' || + (Array.isArray(leaf.value) && leaf.value.length === 0))) + +const keyed = (node) => ({ __id: node.__id || uid(), ...node }) + +const toStringIdArray = (value) => { + if (Array.isArray(value)) return value + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value) + return Array.isArray(parsed) ? parsed.map((v) => String(v)) : [] + } catch { + return [] + } + } + return [] +} + +const withIds = (node) => { + if (isGroupNode(node)) { + return { __id: node.__id || uid(), logic: node.logic || LOGIC.AND, rules: (node.rules || []).map(withIds) } + } + return keyed(node) +} + +// normalizeToTwoLevel coerces any stored shape (legacy flat array, old nested tree, new two-level) +// into a strict { logic, rules: [ {logic, rules:[leaf,...]}, ... ] }: the top level holds only groups, +// and each group holds only leaves. Existing groups are preserved; loose leaves are collected into one +// group so semantics (e.g. status AND (high OR low)) survive the coercion. +export const normalizeToTwoLevel = (filters) => { + if (Array.isArray(filters)) { + return withIds({ logic: LOGIC.AND, rules: [{ logic: LOGIC.AND, rules: filters.map((f) => ({ ...f })) }] }) + } + if (!isGroupNode(filters)) return createRoot() + + const outerLogic = filters.logic || LOGIC.AND + const topRules = filters.rules || [] + const groups = [] + const looseLeaves = [] + for (const r of topRules) { + if (isGroupNode(r)) { + groups.push({ logic: r.logic || LOGIC.AND, rules: collectLeaves(r).map((l) => ({ ...l })) }) + } else { + looseLeaves.push({ ...r }) + } + } + if (looseLeaves.length) groups.unshift({ logic: outerLogic, rules: looseLeaves }) + if (groups.length === 0) groups.push({ logic: LOGIC.AND, rules: [createLeaf()] }) + return withIds({ logic: outerLogic, rules: groups }) +} + +// serializeFilterTree drops UI-only __id and converts multi-select array values to JSON strings of numeric IDs. +export const serializeFilterTree = (node) => { + if (isGroupNode(node)) { + return { logic: node.logic || LOGIC.AND, rules: (node.rules || []).map(serializeFilterTree) } + } + const leaf = { model: node.model, field: node.field, operator: node.operator, value: node.value } + if (Array.isArray(leaf.value)) { + leaf.value = JSON.stringify( + leaf.value.map((v) => { + const num = Number(v) + return isNaN(num) ? v : num + }) + ) + } + return leaf +} + +// deserializeFilterTree restores multi-select string values to string-ID arrays and keeps __id for stable keys. +export const deserializeFilterTree = (node, fields) => { + if (isGroupNode(node)) { + return { __id: node.__id || uid(), logic: node.logic || LOGIC.AND, rules: (node.rules || []).map((n) => deserializeFilterTree(n, fields)) } + } + const field = fields.find((f) => f.field === node.field) + if (field?.type === FIELD_TYPE.MULTI_SELECT) { + return keyed({ ...node, value: toStringIdArray(node.value) }) + } + return keyed(node) +} diff --git a/frontend/apps/main/src/composables/useConversationFilters.js b/frontend/apps/main/src/composables/useConversationFilters.js index 537cbcbb..cc09e94e 100644 --- a/frontend/apps/main/src/composables/useConversationFilters.js +++ b/frontend/apps/main/src/composables/useConversationFilters.js @@ -78,6 +78,68 @@ export function useConversationFilters () { label: t('globals.terms.createdAt'), type: FIELD_TYPE.DATE, operators: FIELD_OPERATORS.DATE + }, + waiting_since: { + label: t('globals.terms.waitingSince'), + type: FIELD_TYPE.DATE, + operators: FIELD_OPERATORS.DATE + }, + snoozed_until: { + label: t('globals.terms.snoozedUntil'), + type: FIELD_TYPE.DATE, + operators: FIELD_OPERATORS.DATE + }, + last_message_at: { + label: t('globals.terms.lastMessageAt'), + type: FIELD_TYPE.DATE, + operators: FIELD_OPERATORS.DATE + }, + last_interaction_at: { + label: t('globals.terms.lastInteractionAt'), + type: FIELD_TYPE.DATE, + operators: FIELD_OPERATORS.DATE + }, + next_sla_deadline_at: { + label: t('globals.terms.nextSlaDeadline'), + type: FIELD_TYPE.DATE, + operators: FIELD_OPERATORS.DATE + }, + email: { + label: t('globals.terms.contactEmail'), + type: FIELD_TYPE.TEXT, + operators: FIELD_OPERATORS.TEXT, + model: 'users' + }, + external_user_id: { + label: t('globals.terms.contactExternalId'), + type: FIELD_TYPE.TEXT, + operators: FIELD_OPERATORS.SELECT, + model: 'users' + }, + last_interaction_sender: { + label: t('globals.terms.lastInteractionBy'), + type: FIELD_TYPE.SELECT, + operators: FIELD_OPERATORS.SELECT, + options: [ + { label: t('globals.terms.contact'), value: 'contact' }, + { label: t('globals.terms.agent'), value: 'agent' } + ] + }, + sla_policy_id: { + label: t('globals.terms.slaPolicy'), + type: FIELD_TYPE.SELECT, + operators: FIELD_OPERATORS.SELECT, + options: slaStore.options + }, + channel: { + label: t('globals.terms.channel'), + type: FIELD_TYPE.SELECT, + operators: FIELD_OPERATORS.SELECT, + options: [ + { label: t('globals.terms.email'), value: 'email' }, + { label: t('globals.terms.liveChat'), value: 'livechat' } + ], + model: 'inboxes' } })) diff --git a/frontend/apps/main/src/constants/filterConfig.js b/frontend/apps/main/src/constants/filterConfig.js index 08435dc5..bdfa7d6b 100644 --- a/frontend/apps/main/src/constants/filterConfig.js +++ b/frontend/apps/main/src/constants/filterConfig.js @@ -1,3 +1,8 @@ +export const LOGIC = { + AND: 'AND', + OR: 'OR' +} + export const FIELD_TYPE = { SELECT: 'select', TAG: 'tag', @@ -21,6 +26,17 @@ export const OPERATOR = { BETWEEN: 'between' } +// operatorLabel returns a clearer display label for operators whose meaning is ambiguous with +// multiple values (contains = matches ANY of the values). Other operators display as-is. +const OPERATOR_LABEL_KEYS = { + [OPERATOR.CONTAINS]: 'filter.containsAnyOf', + [OPERATOR.NOT_CONTAINS]: 'filter.containsNoneOf' +} +export const operatorLabel = (op, t) => { + const key = OPERATOR_LABEL_KEYS[op] + return key ? t(key) : op +} + export const FIELD_OPERATORS = { SELECT: [OPERATOR.EQUALS, OPERATOR.NOT_EQUALS, OPERATOR.SET, OPERATOR.NOT_SET], BOOLEAN: [OPERATOR.EQUALS, OPERATOR.NOT_EQUALS], diff --git a/frontend/apps/main/src/features/admin/agents/AgentForm.vue b/frontend/apps/main/src/features/admin/agents/AgentForm.vue index d72bc98c..2f5957d7 100644 --- a/frontend/apps/main/src/features/admin/agents/AgentForm.vue +++ b/frontend/apps/main/src/features/admin/agents/AgentForm.vue @@ -384,9 +384,9 @@ const isAPIKeyLoading = ref(false) onMounted(async () => { try { - const [teamsResp, rolesResp] = await Promise.allSettled([api.getTeams(), api.getRoles()]) - teams.value = teamsResp.value.data.data - roles.value = rolesResp.value.data.data + const [teamsResp, rolesResp] = await Promise.allSettled([api.getTeamsCompact(), api.getRoles()]) + if (teamsResp.status === 'fulfilled') teams.value = teamsResp.value.data.data + if (rolesResp.status === 'fulfilled') roles.value = rolesResp.value.data.data } catch (err) { emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { variant: 'destructive', diff --git a/frontend/apps/main/src/features/admin/automation/RuleBox.vue b/frontend/apps/main/src/features/admin/automation/RuleBox.vue index 6ecd273e..0c2f3756 100644 --- a/frontend/apps/main/src/features/admin/automation/RuleBox.vue +++ b/frontend/apps/main/src/features/admin/automation/RuleBox.vue @@ -76,7 +76,7 @@ :key="key" :value="op" > - {{ op }} + {{ operatorLabel(op, t) }} @@ -221,6 +221,7 @@ import { Input } from '@shared-ui/components/ui/input' import { useI18n } from 'vue-i18n' import { useConversationFilters } from '../../../composables/useConversationFilters' import SelectComboBox from '@main/components/combobox/SelectCombobox.vue' +import { operatorLabel } from '@/constants/filterConfig' const props = defineProps({ ruleGroup: { diff --git a/frontend/apps/main/src/features/admin/shared-views/SharedViewForm.vue b/frontend/apps/main/src/features/admin/shared-views/SharedViewForm.vue index 05ae829b..1841808b 100644 --- a/frontend/apps/main/src/features/admin/shared-views/SharedViewForm.vue +++ b/frontend/apps/main/src/features/admin/shared-views/SharedViewForm.vue @@ -1,7 +1,7 @@ diff --git a/frontend/apps/main/src/components/filter/FilterGroupCard.vue b/frontend/apps/main/src/components/filter/FilterGroupCard.vue index e814014d..34ab3a20 100644 --- a/frontend/apps/main/src/components/filter/FilterGroupCard.vue +++ b/frontend/apps/main/src/components/filter/FilterGroupCard.vue @@ -13,8 +13,17 @@
@@ -157,19 +157,32 @@ watch(validateTick, async () => { }) onUnmounted(() => clearTimeout(shakeTimer)) +// All edits emit a fresh leaf; the tree is never mutated in place. +const patch = (changes) => { + modelValue.value = { ...modelValue.value, ...changes } +} + +const leafValue = computed({ + get: () => modelValue.value.value, + set: (value) => patch({ value }) +}) + const onFieldChange = (field) => { const config = props.fields.find((f) => f.field === field) - modelValue.value.field = field - modelValue.value.model = config?.model || '' - modelValue.value.operator = '' - modelValue.value.value = config?.type === FIELD_TYPE.MULTI_SELECT ? [] : '' + patch({ + field, + model: config?.model || '', + operator: '', + value: config?.type === FIELD_TYPE.MULTI_SELECT ? [] : '' + }) } const onOperatorChange = (operator) => { if (modelValue.value.operator === operator) return if (modelValue.value.operator === OPERATOR.BETWEEN || operator === OPERATOR.BETWEEN) { - modelValue.value.value = '' + patch({ operator, value: '' }) + return } - modelValue.value.operator = operator + patch({ operator }) } diff --git a/frontend/apps/main/src/components/filter/filterTree.js b/frontend/apps/main/src/components/filter/filterTree.js index 33681036..65a40da7 100644 --- a/frontend/apps/main/src/components/filter/filterTree.js +++ b/frontend/apps/main/src/components/filter/filterTree.js @@ -12,6 +12,17 @@ export const createRoot = (logic = LOGIC.AND) => ({ __id: uid(), logic, rules: [ export const isGroupNode = (node) => !!node && (Array.isArray(node.rules) || typeof node.logic === 'string') +// Strict two-level shape: an object root holding only groups, each group holding only leaves. +export const isStrictTwoLevel = (node) => + !!node && + typeof node === 'object' && + !Array.isArray(node) && + Array.isArray(node.rules) && + node.rules.length > 0 && + node.rules.every( + (g) => isGroupNode(g) && Array.isArray(g.rules) && g.rules.every((r) => !isGroupNode(r)) + ) + export const collectLeaves = (node) => isGroupNode(node) ? (node.rules || []).flatMap(collectLeaves) : [node] diff --git a/frontend/apps/main/src/features/view/ViewForm.vue b/frontend/apps/main/src/features/view/ViewForm.vue index a99f4390..dd4abc01 100644 --- a/frontend/apps/main/src/features/view/ViewForm.vue +++ b/frontend/apps/main/src/features/view/ViewForm.vue @@ -97,6 +97,8 @@ const nameInputRef = ref(null) const openDialog = defineModel('openDialog', { required: false, default: false }) watch(openDialog, (isOpen) => { if (isOpen) { + // A cancelled edit leaves the previous view in the form; reset before a fresh create. + if (!view.value?.id) form.resetForm() nextTick(() => { nameInputRef.value?.$el?.focus() }) From 20b3aec44e843e2c94f89dacd4bcee510965ae22 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Thu, 11 Jun 2026 14:15:31 +0530 Subject: [PATCH 92/97] fix: restrict contains operator to trigram-indexed fields in view filters contains on users.external_user_id has no trigram index and would full-scan; only users.email keeps partial matching. --- frontend/apps/main/src/composables/useConversationFilters.js | 2 +- frontend/apps/main/src/constants/filterConfig.js | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/apps/main/src/composables/useConversationFilters.js b/frontend/apps/main/src/composables/useConversationFilters.js index e66900c4..0a8d4105 100644 --- a/frontend/apps/main/src/composables/useConversationFilters.js +++ b/frontend/apps/main/src/composables/useConversationFilters.js @@ -113,7 +113,7 @@ export function useConversationFilters () { external_user_id: { label: t('globals.terms.contactExternalId'), type: FIELD_TYPE.TEXT, - operators: FIELD_OPERATORS.TEXT, + operators: FIELD_OPERATORS.TEXT_EXACT, model: 'users' }, last_interaction_sender: { diff --git a/frontend/apps/main/src/constants/filterConfig.js b/frontend/apps/main/src/constants/filterConfig.js index bdfa7d6b..3f924707 100644 --- a/frontend/apps/main/src/constants/filterConfig.js +++ b/frontend/apps/main/src/constants/filterConfig.js @@ -48,6 +48,8 @@ export const FIELD_OPERATORS = { OPERATOR.CONTAINS, OPERATOR.NOT_CONTAINS ], + // For text columns that do not support partial matching, only allow exact match operators. + TEXT_EXACT: [OPERATOR.EQUALS, OPERATOR.NOT_EQUALS, OPERATOR.SET, OPERATOR.NOT_SET], DATE: [ OPERATOR.EQUALS, OPERATOR.NOT_EQUALS, From 54e30e2e717b2fd243fa846c9780ebf0681ab815 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Thu, 11 Jun 2026 18:03:19 +0530 Subject: [PATCH 93/97] feat: disable 'Add group' in filter builder at the backend group cap --- .../main/src/components/filter/FilterGroupBuilder.vue | 10 +++++++++- frontend/apps/main/src/constants/filterConfig.js | 3 +++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/frontend/apps/main/src/components/filter/FilterGroupBuilder.vue b/frontend/apps/main/src/components/filter/FilterGroupBuilder.vue index fde2b34c..b0129771 100644 --- a/frontend/apps/main/src/components/filter/FilterGroupBuilder.vue +++ b/frontend/apps/main/src/components/filter/FilterGroupBuilder.vue @@ -13,7 +13,13 @@ /> - @@ -33,6 +39,7 @@ import { normalizeToTwoLevel, isStrictTwoLevel } from '@/components/filter/filterTree' +import { MAX_FILTER_GROUPS } from '@/constants/filterConfig' // vee-validate's componentField carries onInput/onChange listeners; without this they fall // through to the root div and bubbled keystrokes overwrite the whole filters value. @@ -63,6 +70,7 @@ const updateGroup = (index, group) => { } const addGroup = () => { + if (modelValue.value.rules.length >= MAX_FILTER_GROUPS) return modelValue.value = { ...modelValue.value, rules: [...modelValue.value.rules, createGroup()] } } diff --git a/frontend/apps/main/src/constants/filterConfig.js b/frontend/apps/main/src/constants/filterConfig.js index 3f924707..428cde51 100644 --- a/frontend/apps/main/src/constants/filterConfig.js +++ b/frontend/apps/main/src/constants/filterConfig.js @@ -3,6 +3,9 @@ export const LOGIC = { OR: 'OR' } +// Mirrors dbutil.MaxFilterGroups on the backend. +export const MAX_FILTER_GROUPS = 10 + export const FIELD_TYPE = { SELECT: 'select', TAG: 'tag', From 85a200247b3155a581b8df8b1c2174c7a5123dbb Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Tue, 16 Jun 2026 23:25:50 +0530 Subject: [PATCH 94/97] keeps filter builder Add group button visible and tidies group controls --- .../components/filter/FilterGroupBuilder.vue | 26 ++++++++++--------- .../src/components/filter/FilterGroupCard.vue | 17 +++++------- .../admin/shared-views/SharedViewForm.vue | 4 +-- .../features/conversation/Conversation.vue | 2 +- .../apps/main/src/features/view/ViewForm.vue | 4 +-- 5 files changed, 23 insertions(+), 30 deletions(-) diff --git a/frontend/apps/main/src/components/filter/FilterGroupBuilder.vue b/frontend/apps/main/src/components/filter/FilterGroupBuilder.vue index b0129771..41c37ca5 100644 --- a/frontend/apps/main/src/components/filter/FilterGroupBuilder.vue +++ b/frontend/apps/main/src/components/filter/FilterGroupBuilder.vue @@ -1,17 +1,19 @@