Skip to content

Commit 823f3ce

Browse files
committed
feat: Add conversation merge and enhanced search functionality
- Add merge conversations feature with modal UI - Add conversations:merge permission with role management - Display ticket reference number in conversation list - Enhance search to support partial matching: - Contact name (first, last, full name) - Email address (partial) - Subject/title - Reference number - Add merge columns and permission to v0.9.0 migration
1 parent 02fb840 commit 823f3ce

File tree

15 files changed

+530
-13
lines changed

15 files changed

+530
-13
lines changed

cmd/conversation.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -799,3 +799,53 @@ func validateCreateConversationRequest(req createConversationRequest, app *App)
799799

800800
return nil
801801
}
802+
803+
// handleMergeConversations merges secondary conversations into the primary.
804+
func handleMergeConversations(r *fastglue.Request) error {
805+
var (
806+
app = r.Context.(*App)
807+
uuid = r.RequestCtx.UserValue("uuid").(string)
808+
auser = r.RequestCtx.UserValue("user").(amodels.User)
809+
)
810+
811+
var req struct {
812+
SecondaryUUIDs []string `json:"secondary_conversation_uuids"`
813+
}
814+
815+
if err := r.Decode(&req, "json"); err != nil {
816+
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.badRequest"), nil, envelope.InputError)
817+
}
818+
819+
if len(req.SecondaryUUIDs) == 0 {
820+
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "At least one conversation to merge is required", nil, envelope.InputError)
821+
}
822+
823+
if err := app.conversation.MergeConversations(uuid, req.SecondaryUUIDs, auser.ID); err != nil {
824+
return sendErrorEnvelope(r, err)
825+
}
826+
827+
return r.SendEnvelope(map[string]interface{}{
828+
"merged_count": len(req.SecondaryUUIDs),
829+
"primary_conversation_uuid": uuid,
830+
})
831+
}
832+
833+
// handleSearchConversationsForMerge searches for conversations to merge.
834+
func handleSearchConversationsForMerge(r *fastglue.Request) error {
835+
var (
836+
app = r.Context.(*App)
837+
excludeUUID = r.RequestCtx.UserValue("uuid").(string)
838+
query = string(r.RequestCtx.QueryArgs().Peek("q"))
839+
)
840+
841+
if query == "" {
842+
return r.SendEnvelope([]interface{}{})
843+
}
844+
845+
results, err := app.conversation.SearchConversationsForMerge(excludeUUID, query)
846+
if err != nil {
847+
return sendErrorEnvelope(r, err)
848+
}
849+
850+
return r.SendEnvelope(results)
851+
}

cmd/handlers.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
7575
g.GET("/api/v1/conversations/{cuuid}/messages/{uuid}", perm(handleGetMessage, "messages:read"))
7676
g.GET("/api/v1/conversations/{uuid}/messages", perm(handleGetMessages, "messages:read"))
7777
g.POST("/api/v1/conversations/{cuuid}/messages", perm(handleSendMessage, "messages:write"))
78+
g.POST("/api/v1/conversations/{uuid}/merge", perm(handleMergeConversations, "conversations:merge"))
79+
g.GET("/api/v1/conversations/{uuid}/merge/search", perm(handleSearchConversationsForMerge, "conversations:merge"))
7880
g.PUT("/api/v1/conversations/{cuuid}/messages/{uuid}/retry", perm(handleRetryMessage, "messages:write"))
7981
g.POST("/api/v1/conversations", perm(handleCreateConversation, "conversations:write"))
8082
g.PUT("/api/v1/conversations/{uuid}/custom-attributes", auth(handleUpdateConversationCustomAttributes))

frontend/src/api/index.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,8 @@ const sendMessage = (uuid, data) =>
318318
}
319319
})
320320
const getConversation = (uuid) => http.get(`/api/v1/conversations/${uuid}`)
321+
const mergeConversations = (uuid, secondaryUuids) => http.post(`/api/v1/conversations/${uuid}/merge`, { secondary_conversation_uuids: secondaryUuids })
322+
const searchConversationsForMerge = (uuid, query) => http.get(`/api/v1/conversations/${uuid}/merge/search`, { params: { q: query } })
321323
const getConversationParticipants = (uuid) => http.get(`/api/v1/conversations/${uuid}/participants`)
322324
const getAllMacros = () => http.get('/api/v1/macros')
323325
const getMacro = (id) => http.get(`/api/v1/macros/${id}`)
@@ -479,6 +481,8 @@ export default {
479481
getInboxes,
480482
getLanguage,
481483
getConversation,
484+
mergeConversations,
485+
searchConversationsForMerge,
482486
getAutomationRule,
483487
getAutomationRules,
484488
getAllBusinessHours,
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
<template>
2+
<Dialog :open="open" @update:open="$emit('update:open', $event)">
3+
<DialogContent class="sm:max-w-xl">
4+
<DialogHeader>
5+
<DialogTitle>Merge Conversations</DialogTitle>
6+
<DialogDescription>
7+
Search for conversations to merge into this one. This action cannot be undone.
8+
</DialogDescription>
9+
</DialogHeader>
10+
11+
<div class="space-y-4">
12+
<Input
13+
v-model="searchQuery"
14+
placeholder="Search by reference #, subject, or contact..."
15+
@input="debouncedSearch"
16+
/>
17+
18+
<div v-if="isSearching" class="flex justify-center py-4">
19+
<Spinner />
20+
</div>
21+
22+
<div v-else-if="searchResults.length > 0" class="max-h-64 overflow-y-auto space-y-2">
23+
<div
24+
v-for="conv in searchResults"
25+
:key="conv.uuid"
26+
class="flex items-center gap-3 p-3 border rounded-lg cursor-pointer hover:bg-muted"
27+
:class="{ 'border-primary bg-primary/5': selectedUuids.includes(conv.uuid) }"
28+
@click="toggleSelection(conv.uuid)"
29+
>
30+
<Checkbox :checked="selectedUuids.includes(conv.uuid)" />
31+
<div class="flex-1 min-w-0">
32+
<div class="flex items-center gap-2">
33+
<span class="font-mono text-sm text-muted-foreground">#{{ conv.reference_number }}</span>
34+
<Badge variant="outline" class="text-xs">{{ conv.status }}</Badge>
35+
</div>
36+
<div class="text-sm font-medium truncate">{{ conv.subject || 'No subject' }}</div>
37+
<div class="text-xs text-muted-foreground">
38+
{{ conv.contact.first_name }} {{ conv.contact.last_name }}
39+
</div>
40+
</div>
41+
</div>
42+
</div>
43+
44+
<div v-else-if="searchQuery && !isSearching" class="text-center py-4 text-muted-foreground">
45+
No conversations found
46+
</div>
47+
</div>
48+
49+
<DialogFooter class="flex gap-2">
50+
<Button variant="outline" @click="$emit('update:open', false)">Cancel</Button>
51+
<Button
52+
variant="destructive"
53+
:disabled="selectedUuids.length === 0 || isMerging"
54+
@click="confirmMerge"
55+
>
56+
<Spinner v-if="isMerging" class="mr-2 h-4 w-4" />
57+
Merge {{ selectedUuids.length }} conversation{{ selectedUuids.length !== 1 ? 's' : '' }}
58+
</Button>
59+
</DialogFooter>
60+
</DialogContent>
61+
</Dialog>
62+
63+
<AlertDialog :open="showConfirm" @update:open="showConfirm = $event">
64+
<AlertDialogContent>
65+
<AlertDialogHeader>
66+
<AlertDialogTitle>Confirm Merge</AlertDialogTitle>
67+
<AlertDialogDescription>
68+
You are about to merge {{ selectedUuids.length }} conversation(s) into this one.
69+
All messages will be combined and the merged conversations will be closed.
70+
This action cannot be undone.
71+
</AlertDialogDescription>
72+
</AlertDialogHeader>
73+
<AlertDialogFooter>
74+
<AlertDialogCancel>Cancel</AlertDialogCancel>
75+
<AlertDialogAction @click="executeMerge" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">
76+
Merge
77+
</AlertDialogAction>
78+
</AlertDialogFooter>
79+
</AlertDialogContent>
80+
</AlertDialog>
81+
</template>
82+
83+
<script setup>
84+
import { ref, watch } from 'vue'
85+
import { useDebounceFn } from '@vueuse/core'
86+
import api from '@/api'
87+
import { useEmitter } from '@/composables/useEmitter'
88+
import { EMITTER_EVENTS } from '@/constants/emitterEvents'
89+
import { handleHTTPError } from '@/utils/http'
90+
import {
91+
Dialog,
92+
DialogContent,
93+
DialogDescription,
94+
DialogFooter,
95+
DialogHeader,
96+
DialogTitle
97+
} from '@/components/ui/dialog'
98+
import {
99+
AlertDialog,
100+
AlertDialogAction,
101+
AlertDialogCancel,
102+
AlertDialogContent,
103+
AlertDialogDescription,
104+
AlertDialogFooter,
105+
AlertDialogHeader,
106+
AlertDialogTitle
107+
} from '@/components/ui/alert-dialog'
108+
import { Input } from '@/components/ui/input'
109+
import { Button } from '@/components/ui/button'
110+
import { Checkbox } from '@/components/ui/checkbox'
111+
import { Badge } from '@/components/ui/badge'
112+
import { Spinner } from '@/components/ui/spinner'
113+
114+
const props = defineProps({
115+
open: Boolean,
116+
conversationUuid: String
117+
})
118+
119+
const emit = defineEmits(['update:open', 'merged'])
120+
121+
const emitter = useEmitter()
122+
const searchQuery = ref('')
123+
const searchResults = ref([])
124+
const selectedUuids = ref([])
125+
const isSearching = ref(false)
126+
const isMerging = ref(false)
127+
const showConfirm = ref(false)
128+
129+
const search = async () => {
130+
if (!searchQuery.value.trim()) {
131+
searchResults.value = []
132+
return
133+
}
134+
135+
isSearching.value = true
136+
try {
137+
const resp = await api.searchConversationsForMerge(props.conversationUuid, searchQuery.value)
138+
searchResults.value = resp.data.data || []
139+
} catch (err) {
140+
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
141+
variant: 'destructive',
142+
description: handleHTTPError(err).message
143+
})
144+
} finally {
145+
isSearching.value = false
146+
}
147+
}
148+
149+
const debouncedSearch = useDebounceFn(search, 300)
150+
151+
const toggleSelection = (uuid) => {
152+
const idx = selectedUuids.value.indexOf(uuid)
153+
if (idx === -1) {
154+
selectedUuids.value.push(uuid)
155+
} else {
156+
selectedUuids.value.splice(idx, 1)
157+
}
158+
}
159+
160+
const confirmMerge = () => {
161+
showConfirm.value = true
162+
}
163+
164+
const executeMerge = async () => {
165+
showConfirm.value = false
166+
isMerging.value = true
167+
168+
try {
169+
await api.mergeConversations(props.conversationUuid, selectedUuids.value)
170+
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
171+
description: `Merged ${selectedUuids.value.length} conversation(s) successfully`
172+
})
173+
emit('merged')
174+
emit('update:open', false)
175+
} catch (err) {
176+
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
177+
variant: 'destructive',
178+
description: handleHTTPError(err).message
179+
})
180+
} finally {
181+
isMerging.value = false
182+
}
183+
}
184+
185+
watch(() => props.open, (isOpen) => {
186+
if (!isOpen) {
187+
searchQuery.value = ''
188+
searchResults.value = []
189+
selectedUuids.value = []
190+
}
191+
})
192+
</script>

frontend/src/constants/permissions.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export const permissions = {
22
CONVERSATIONS_READ: 'conversations:read',
33
CONVERSATIONS_WRITE: 'conversations:write',
4+
CONVERSATIONS_MERGE: 'conversations:merge',
45
CONVERSATIONS_READ_ASSIGNED: 'conversations:read_assigned',
56
CONVERSATIONS_READ_ALL: 'conversations:read_all',
67
CONVERSATIONS_READ_UNASSIGNED: 'conversations:read_unassigned',

frontend/src/features/admin/roles/RoleForm.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ const permissions = ref([
108108
permissions: [
109109
{ name: perms.CONVERSATIONS_READ, label: t('admin.role.conversations.read') },
110110
{ name: perms.CONVERSATIONS_WRITE, label: t('admin.role.conversations.write') },
111+
{ name: perms.CONVERSATIONS_MERGE, label: t('admin.role.conversations.merge') },
111112
{
112113
name: perms.CONVERSATIONS_READ_ASSIGNED,
113114
label: t('admin.role.conversations.readAssigned')

frontend/src/features/conversation/list/ConversationListItem.vue

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,16 @@
2121

2222
<!-- Content container -->
2323
<div class="flex-1 min-w-0 space-y-2">
24-
<!-- Contact name and last message time -->
24+
<!-- Contact name, reference number, and last message time -->
2525
<div class="flex items-center justify-between gap-2">
26-
<h3 class="text-sm font-semibold truncate">
27-
{{ contactFullName }}
28-
</h3>
26+
<div class="flex items-center gap-2 min-w-0">
27+
<h3 class="text-sm font-semibold truncate">
28+
{{ contactFullName }}
29+
</h3>
30+
<span v-if="conversation.reference_number" class="text-xs font-mono text-muted-foreground whitespace-nowrap">
31+
#{{ conversation.reference_number }}
32+
</span>
33+
</div>
2934
<span class="text-xs text-gray-400 whitespace-nowrap" v-if="conversation.last_message_at">
3035
{{ relativeLastMessageTime }}
3136
</span>

frontend/src/features/conversation/sidebar/ConversationSideBar.vue

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,18 @@
5151
t('globals.messages.select', { name: t('globals.terms.tag', 2).toLowerCase() })
5252
"
5353
/>
54+
55+
<!-- Merge Conversations -->
56+
<Button
57+
v-if="userStore.can('conversations:merge')"
58+
variant="outline"
59+
size="sm"
60+
class="w-full"
61+
@click="showMergeModal = true"
62+
>
63+
<GitMergeIcon class="h-4 w-4 mr-2" />
64+
Merge Conversations
65+
</Button>
5466
</AccordionContent>
5567
</AccordionItem>
5668

@@ -94,6 +106,13 @@
94106
</AccordionItem>
95107
</Accordion>
96108
</div>
109+
110+
<!-- Merge Modal -->
111+
<MergeConversationModal
112+
v-model:open="showMergeModal"
113+
:conversation-uuid="conversationStore.current?.uuid"
114+
@merged="refreshConversation"
115+
/>
97116
</template>
98117

99118
<script setup>
@@ -102,6 +121,10 @@ import { useConversationStore } from '@/stores/conversation'
102121
import { useUsersStore } from '@/stores/users'
103122
import { useTeamStore } from '@/stores/team'
104123
import { useTagStore } from '@/stores/tag'
124+
import { useUserStore } from "@/stores/user"
125+
import { GitMergeIcon } from "lucide-vue-next"
126+
import { Button } from "@/components/ui/button"
127+
import MergeConversationModal from "@/components/conversation/MergeConversationModal.vue"
105128
import {
106129
Accordion,
107130
AccordionContent,
@@ -129,6 +152,15 @@ const usersStore = useUsersStore()
129152
const teamsStore = useTeamStore()
130153
const tagStore = useTagStore()
131154
const tags = ref([])
155+
const userStore = useUserStore()
156+
const showMergeModal = ref(false)
157+
158+
const refreshConversation = () => {
159+
if (conversationStore.current?.uuid) {
160+
conversationStore.fetchConversation(conversationStore.current.uuid)
161+
conversationStore.fetchMessages(conversationStore.current.uuid)
162+
}
163+
}
132164
// Save the accordion state in local storage
133165
const accordionState = useStorage('conversation-sidebar-accordion', [])
134166
const { t } = useI18n()

i18n/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,7 @@
491491
"admin.role.cannotModifyAdminRole": "Cannot modify admin role, Please create a new role.",
492492
"admin.role.conversations.read": "View conversation",
493493
"admin.role.conversations.write": "Create conversation",
494+
"admin.role.conversations.merge": "Merge Conversations",
494495
"admin.role.conversations.readAssigned": "View conversations assigned to me",
495496
"admin.role.conversations.readAll": "View all conversations",
496497
"admin.role.conversations.readUnassigned": "View all unassigned conversations",

internal/authz/models/models.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const (
1313
PermConversationsUpdateStatus = "conversations:update_status"
1414
PermConversationsUpdateTags = "conversations:update_tags"
1515
PermConversationWrite = "conversations:write"
16+
PermConversationMerge = "conversations:merge"
1617
PermMessagesRead = "messages:read"
1718
PermMessagesWrite = "messages:write"
1819
PermMessagesWriteAsContact = "messages:write_as_contact"
@@ -102,6 +103,7 @@ var validPermissions = map[string]struct{}{
102103
PermConversationsUpdateStatus: {},
103104
PermConversationsUpdateTags: {},
104105
PermConversationWrite: {},
106+
PermConversationMerge: {},
105107
PermMessagesRead: {},
106108
PermMessagesWrite: {},
107109
PermMessagesWriteAsContact: {},

0 commit comments

Comments
 (0)