feat(conversations): add trigram index for searching ref numbers

feat(messages): add trigram index for text content search
- feat: UI animations for conversation and messages list.
- Simplify websocket updates.
This commit is contained in:
Abhinav Raut
2025-01-19 23:10:24 +05:30
parent 29c341d5f3
commit 0c01b53b09
62 changed files with 1809 additions and 1181 deletions

View File

@@ -22,13 +22,13 @@ $(STUFFBIN):
.PHONY: install-deps
install-deps: $(STUFFBIN)
@echo "→ Installing frontend dependencies..."
@cd ${FRONTEND_DIR} && npm install
@cd ${FRONTEND_DIR} && pnpm install
# Frontend builds
.PHONY: frontend-build
frontend-build:
@echo "→ Building frontend for production..."
@cd ${FRONTEND_DIR} && bun run build
@cd ${FRONTEND_DIR} && pnpm build
# Backend builds
.PHONY: backend-build
@@ -43,6 +43,7 @@ backend-build: $(STUFFBIN)
build: frontend-build backend-build stuff
@echo "→ Build successful. Current version: $(VERSION)"
# Stuff static assets into binary
.PHONY: stuff
stuff: $(STUFFBIN)
@echo "→ Stuffing static assets into binary..."

View File

@@ -64,6 +64,10 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.POST("/api/v1/conversations/{cuuid}/messages", perm(handleSendMessage, "messages:write"))
g.PUT("/api/v1/conversations/{cuuid}/messages/{uuid}/retry", perm(handleRetryMessage, "messages:write"))
// Search.
g.GET("/api/v1/conversations/search", perm(handleSearchConversations, "conversations:read"))
g.GET("/api/v1/messages/search", perm(handleSearchMessages, "messages:read"))
// Views.
g.GET("/api/v1/views/me", perm(handleGetUserViews, "view:manage"))
g.POST("/api/v1/views/me", perm(handleCreateUserView, "view:manage"))

View File

@@ -33,6 +33,7 @@ import (
emailnotifier "github.com/abhinavxd/libredesk/internal/notification/providers/email"
"github.com/abhinavxd/libredesk/internal/oidc"
"github.com/abhinavxd/libredesk/internal/role"
"github.com/abhinavxd/libredesk/internal/search"
"github.com/abhinavxd/libredesk/internal/setting"
"github.com/abhinavxd/libredesk/internal/sla"
"github.com/abhinavxd/libredesk/internal/tag"
@@ -711,6 +712,19 @@ func initAI(db *sqlx.DB) *ai.Manager {
return m
}
// initSearch inits search manager.
func initSearch(db *sqlx.DB) *search.Manager {
lo := initLogger("search")
m, err := search.New(search.Opts{
DB: db,
Lo: lo,
})
if err != nil {
log.Fatalf("error initializing search manager: %v", err)
}
return m
}
// initLogger initializes a logf logger.
func initLogger(src string) *logf.Logger {
lvl, env := ko.MustString("app.log_level"), ko.MustString("app.env")

View File

@@ -16,6 +16,7 @@ import (
"github.com/abhinavxd/libredesk/internal/csat"
"github.com/abhinavxd/libredesk/internal/macro"
notifier "github.com/abhinavxd/libredesk/internal/notification"
"github.com/abhinavxd/libredesk/internal/search"
"github.com/abhinavxd/libredesk/internal/sla"
"github.com/abhinavxd/libredesk/internal/view"
@@ -75,6 +76,7 @@ type App struct {
csat *csat.Manager
view *view.Manager
ai *ai.Manager
search *search.Manager
notifier *notifier.Service
}
@@ -154,8 +156,8 @@ func main() {
conversation = initConversations(i18n, sla, status, priority, wsHub, notifier, db, inbox, user, team, media, automation, template)
autoassigner = initAutoAssigner(team, user, conversation)
)
// Set stores.
wsHub.SetConversationStore(conversation)
// Set store.
automation.SetConversationStore(conversation)
// Start inbox receivers.
@@ -203,9 +205,10 @@ func main() {
conversation: conversation,
automation: automation,
businessHours: businessHours,
authz: initAuthz(),
view: initView(db),
csat: initCSAT(db),
authz: initAuthz(),
search: initSearch(db),
role: initRole(db),
tag: initTag(db),
macro: initMacro(db),

View File

@@ -99,7 +99,7 @@ func handleRetryMessage(r *fastglue.Request) error {
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
cuuid = r.RequestCtx.UserValue("cuuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
@@ -124,7 +124,7 @@ func handleRetryMessage(r *fastglue.Request) error {
func handleSendMessage(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
auser = r.RequestCtx.UserValue("user").(amodels.User)
cuuid = r.RequestCtx.UserValue("cuuid").(string)
req = messageReq{}
media = []medModels.Media{}

29
cmd/search.go Normal file
View File

@@ -0,0 +1,29 @@
package main
import "github.com/zerodha/fastglue"
// handleSearchConversations searches conversations based on the query.
func handleSearchConversations(r *fastglue.Request) error {
var (
app = r.Context.(*App)
q = string(r.RequestCtx.QueryArgs().Peek("query"))
)
conversations, err := app.search.Conversations(q)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(conversations)
}
// handleSearchMessages searches messages based on the query.
func handleSearchMessages(r *fastglue.Request) error {
var (
app = r.Context.(*App)
q = string(r.RequestCtx.QueryArgs().Peek("query"))
)
messages, err := app.search.Messages(q)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(messages)
}

View File

@@ -11,41 +11,40 @@ import (
"github.com/zerodha/fastglue"
)
// ErrHandler is a custom error handler.
func ErrHandler(ctx *fasthttp.RequestCtx, status int, reason error) {
fmt.Printf("error status %d: %s", status, reason)
}
// upgrader is a websocket upgrader.
var upgrader = websocket.FastHTTPUpgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
ReadBufferSize: 8192,
WriteBufferSize: 8192,
CheckOrigin: func(ctx *fasthttp.RequestCtx) bool {
return true
},
Error: ErrHandler,
}
// handleWS handles the websocket connection.
func handleWS(r *fastglue.Request, hub *ws.Hub) error {
var (
auser = r.RequestCtx.UserValue("user").(amodels.User)
app = r.Context.(*App)
)
user, err := app.user.Get(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
err = upgrader.Upgrade(r.RequestCtx, func(conn *websocket.Conn) {
err := upgrader.Upgrade(r.RequestCtx, func(conn *websocket.Conn) {
c := ws.Client{
ID: user.ID,
ID: auser.ID,
Hub: hub,
Conn: conn,
Send: make(chan wsmodels.WSMessage, 1000),
Send: make(chan wsmodels.WSMessage, 10000),
}
hub.AddClient(&c)
go c.Listen()
c.Serve()
})
if err != nil {
app.lo.Error("error upgrading tcp connection", "error", err)
app.lo.Error("error upgrading tcp connection", "user_id", auser.ID, "error", err)
}
return nil
}

View File

@@ -6,7 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap"
<link
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"
rel="stylesheet">
</head>

View File

@@ -1,6 +1,6 @@
<template>
<Toaster />
<TooltipProvider :delay-duration="200">
<TooltipProvider :delay-duration="100">
<div class="!font-jakarta">
<RouterView />
</div>

View File

@@ -33,6 +33,8 @@ http.interceptors.request.use((request) => {
return request
})
const searchConversations = (params) => http.get('/api/v1/conversations/search', { params })
const searchMessages = (params) => http.get('/api/v1/messages/search', { params })
const resetPassword = (data) => http.post('/api/v1/users/reset-password', data)
const setPassword = (data) => http.post('/api/v1/users/set-password', data)
const deleteUser = (id) => http.delete(`/api/v1/users/${id}`)
@@ -363,5 +365,7 @@ export default {
updateView,
deleteView,
getAiPrompts,
aiCompletion
aiCompletion,
searchConversations,
searchMessages
}

View File

@@ -26,66 +26,67 @@ body {
}
// Theme.
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--popover-foreground: 240 10% 3.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--destructive-foreground: 0 0% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--destructive-foreground: 0 0% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
}
}
@@ -152,6 +153,7 @@ body {
pb-3
min-w-[30%] max-w-[70%]
border
overflow-x-auto
rounded-xl;
box-shadow: 1px 1px 1px 0px rgba(0, 0, 0, 0.1);
table {
@@ -168,7 +170,7 @@ body {
}
.box {
@apply border shadow-sm;
@apply border shadow;
}
.dashboard-card {
@@ -291,4 +293,4 @@ a[data-active='false']:hover {
blockquote {
@apply hidden;
}
}
}

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex flex-col p-4 border rounded-lg shadow-sm hover:shadow transition-colors cursor-pointer bg-white max-w-xs"
class="flex flex-col p-4 border rounded-lg shadow-sm hover:shadow transition-colors cursor-pointer max-w-xs"
@click="handleClick">
<div class="flex items-center mb-2">
<component :is="icon" size="24" class="mr-2 text-primary" />

View File

@@ -4,28 +4,40 @@
<div class="p-2 border-b flex items-center justify-between">
<div class="flex items-center space-x-3 text-sm">
<div class="font-medium">
{{ conversationStore.current.subject }}
{{ conversationStore.current?.subject }}
</div>
</div>
<div>
<DropdownMenu>
<DropdownMenuTrigger>
<div class="flex items-center space-x-1 cursor-pointer bg-primary px-2 py-1 rounded-md text-sm">
<div
class="flex items-center space-x-1 cursor-pointer bg-primary px-2 py-1 rounded-md text-sm"
>
<GalleryVerticalEnd size="14" class="text-secondary" />
<span class="text-secondary font-medium">{{ conversationStore.current.status }}</span>
<span class="text-secondary font-medium" v-if="conversationStore.current?.status">
{{ conversationStore.current?.status }}
</span>
<span v-else class="text-secondary font-medium">
Loading...
</span>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem v-for="status in conversationStore.statusOptions" :key="status.value"
@click="handleUpdateStatus(status.label)">
<DropdownMenuItem
v-for="status in conversationStore.statusOptions"
:key="status.value"
@click="handleUpdateStatus(status.label)"
>
{{ status.label }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
<!-- Messages & reply box -->
<!-- Messages & reply box -->
<div>
<div class="flex flex-col h-[calc(100vh-theme(spacing.10))]">
<MessageList class="flex-1 overflow-y-auto" />
<div class="sticky bottom-0 bg-white">
@@ -43,9 +55,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import {
GalleryVerticalEnd,
} from 'lucide-vue-next'
import { GalleryVerticalEnd } from 'lucide-vue-next'
import MessageList from '@/components/message/MessageList.vue'
import ReplyBox from './ReplyBox.vue'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'

View File

@@ -1,7 +1,6 @@
<template>
<div>
<!-- Fullscreen editor -->
<!-- TODO: Has to be a better way to do this than creating two separate editor components -->
<Dialog :open="isEditorFullscreen" @update:open="isEditorFullscreen = $event">
<DialogContent class="max-w-[70%] max-h-[70%] h-[70%] m-0 p-6">
<div v-if="isEditorFullscreen">
@@ -24,70 +23,72 @@
</DialogContent>
</Dialog>
<!-- Main Editor non-fullscreen -->
<div class="border-t" v-if="!isEditorFullscreen">
<!-- Message type toggle -->
<div class="flex justify-between px-2 border-b py-2">
<Tabs v-model="messageType">
<TabsList>
<TabsTrigger value="reply"> Reply </TabsTrigger>
<TabsTrigger value="private_note"> Private note </TabsTrigger>
</TabsList>
</Tabs>
<div
class="flex items-center mr-2 cursor-pointer"
@click="isEditorFullscreen = !isEditorFullscreen"
>
<Fullscreen size="20" />
<div class="m-3 border rounded-xl box">
<!-- Main Editor non-fullscreen -->
<div v-if="!isEditorFullscreen">
<!-- Message type toggle -->
<div class="flex justify-between px-2 border-b py-2">
<Tabs v-model="messageType">
<TabsList>
<TabsTrigger value="reply"> Reply </TabsTrigger>
<TabsTrigger value="private_note"> Private note </TabsTrigger>
</TabsList>
</Tabs>
<div
class="flex items-center mr-2 cursor-pointer"
@click="isEditorFullscreen = !isEditorFullscreen"
>
<Maximize2 size="16" />
</div>
</div>
<!-- Main Editor -->
<Editor
v-model:selectedText="selectedText"
v-model:isBold="isBold"
v-model:isItalic="isItalic"
v-model:htmlContent="htmlContent"
v-model:textContent="textContent"
:placeholder="editorPlaceholder"
:aiPrompts="aiPrompts"
@aiPromptSelected="handleAiPromptSelected"
:contentToSet="contentToSet"
@send="handleSend"
v-model:cursorPosition="cursorPosition"
:clearContent="clearEditorContent"
:setInlineImage="setInlineImage"
:insertContent="insertContent"
/>
<!-- Macro preview -->
<MacroActionsPreview
v-if="conversationStore.conversation?.macro?.actions?.length > 0"
:actions="conversationStore.conversation.macro.actions"
:onRemove="conversationStore.removeMacroAction"
/>
<!-- Attachments preview -->
<AttachmentsPreview
:attachments="attachments"
:onDelete="handleOnFileDelete"
v-if="attachments.length > 0"
/>
<!-- Bottom menu bar -->
<ReplyBoxBottomMenuBar
class="mt-1"
:handleFileUpload="handleFileUpload"
:handleInlineImageUpload="handleInlineImageUpload"
:isBold="isBold"
:isItalic="isItalic"
@toggleBold="toggleBold"
@toggleItalic="toggleItalic"
:enableSend="enableSend"
:handleSend="handleSend"
@emojiSelect="handleEmojiSelect"
>
</ReplyBoxBottomMenuBar>
</div>
<!-- Main Editor -->
<Editor
v-model:selectedText="selectedText"
v-model:isBold="isBold"
v-model:isItalic="isItalic"
v-model:htmlContent="htmlContent"
v-model:textContent="textContent"
:placeholder="editorPlaceholder"
:aiPrompts="aiPrompts"
@aiPromptSelected="handleAiPromptSelected"
:contentToSet="contentToSet"
@send="handleSend"
v-model:cursorPosition="cursorPosition"
:clearContent="clearEditorContent"
:setInlineImage="setInlineImage"
:insertContent="insertContent"
/>
<!-- Macro preview -->
<MacroActionsPreview
v-if="conversationStore.conversation?.macro?.actions?.length > 0"
:actions="conversationStore.conversation.macro.actions"
:onRemove="conversationStore.removeMacroAction"
/>
<!-- Attachments preview -->
<AttachmentsPreview
:attachments="attachments"
:onDelete="handleOnFileDelete"
v-if="attachments.length > 0"
/>
<!-- Bottom menu bar -->
<ReplyBoxBottomMenuBar
class="mt-1"
:handleFileUpload="handleFileUpload"
:handleInlineImageUpload="handleInlineImageUpload"
:isBold="isBold"
:isItalic="isItalic"
@toggleBold="toggleBold"
@toggleItalic="toggleItalic"
:enableSend="enableSend"
:handleSend="handleSend"
@emojiSelect="handleEmojiSelect"
>
</ReplyBoxBottomMenuBar>
</div>
</div>
</template>
@@ -97,7 +98,7 @@ import { ref, onMounted, computed, nextTick, watch } from 'vue'
import { transformImageSrcToCID } from '@/utils/strings'
import { handleHTTPError } from '@/utils/http'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { Fullscreen } from 'lucide-vue-next'
import { Maximize2 } from 'lucide-vue-next'
import api from '@/api'
import Editor from './ConversationTextEditor.vue'
@@ -235,7 +236,6 @@ const handleInlineImageUpload = (event) => {
const handleSend = async () => {
try {
// Send message if there is text content in the editor.
if (hasTextContent.value) {
// Replace inline image url with cid.

View File

@@ -3,7 +3,7 @@
<!-- Header -->
<header class="border-b">
<div class="flex items-center space-x-4 p-2">
<SidebarTrigger class="text-gray-500 hover:text-gray-700 transition-colors" />
<SidebarTrigger class="h-4 w-4" />
<span class="text-xl font-semibold text-gray-800">{{ title }}</span>
</div>
</header>
@@ -13,7 +13,10 @@
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" class="w-30">
{{ conversationStore.getListStatus }}
<div>
<span class="mr-1">{{ conversationStore.conversations.total }}</span>
<span>{{ conversationStore.getListStatus }}</span>
</div>
<ChevronDown class="w-4 h-4 ml-2 opacity-50" />
</Button>
</DropdownMenuTrigger>
@@ -126,7 +129,7 @@
</template>
<script setup>
import { onMounted, computed, onUnmounted, ref } from 'vue'
import { computed, onUnmounted, ref } from 'vue'
import { useConversationStore } from '@/stores/conversation'
import { MessageCircleQuestion, MessageCircleWarning, ChevronDown, Loader2 } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
@@ -154,13 +157,6 @@ const title = computed(() => {
)
})
// FIXME: Figure how to get missed updates.
onMounted(() => {
reFetchInterval.value = setInterval(() => {
conversationStore.reFetchConversationsList(false)
}, 30000)
})
onUnmounted(() => {
clearInterval(reFetchInterval.value)
conversationStore.clearListReRenderInterval()

View File

@@ -1,7 +1,7 @@
<template>
<div
<div
class="relative p-4 transition-all duration-200 ease-in-out cursor-pointer hover:bg-gray-50 border-b border-gray-100 last:border-b-0"
:class="{ 'bg-blue-50': conversation.uuid === currentConversation?.uuid }"
:class="{ 'bg-accent': conversation.uuid === currentConversation?.uuid }"
@click="navigateToConversation(conversation.uuid)"
>
<div class="flex items-start space-x-4">
@@ -27,24 +27,35 @@
<span>{{ conversation.inbox_name }}</span>
</p>
<p class="mt-2 text-sm text-gray-600 line-clamp-2">
<CheckCheck class="inline w-4 h-4 mr-1 text-green-500" />
{{ trimmedLastMessage }}
</p>
<div class="mt-2 flex items-start justify-between">
<p class="text-sm text-gray-600 line-clamp-2 flex-1">
<CheckCheck class="inline-block w-4 h-4 mr-1 text-green-500 flex-shrink-0" />
{{ trimmedLastMessage }}
</p>
<div
v-if="conversation.unread_message_count > 0"
class="flex items-center justify-center w-5 h-5 bg-green-500 text-white text-xs rounded-full flex-shrink-0"
>
{{ conversation.unread_message_count }}
</div>
</div>
<div class="flex items-center mt-2 space-x-2">
<SlaDisplay :dueAt="conversation.first_reply_due_at" :actualAt="conversation.first_reply_at" :label="'FRD'" :showSLAHit="false" />
<SlaDisplay :dueAt="conversation.resolution_due_at" :actualAt="conversation.resolved_at" :label="'RD'" :showSLAHit="false" />
<SlaDisplay
:dueAt="conversation.first_reply_due_at"
:actualAt="conversation.first_reply_at"
:label="'FRD'"
:showSLAHit="false"
/>
<SlaDisplay
:dueAt="conversation.resolution_due_at"
:actualAt="conversation.resolved_at"
:label="'RD'"
:showSLAHit="false"
/>
</div>
</div>
</div>
<div
v-if="conversation.unread_message_count > 0"
class="absolute top-4 right-4 flex items-center justify-center w-6 h-6 bg-blue-500 text-white text-xs font-bold rounded-full"
>
{{ conversation.unread_message_count }}
</div>
</div>
</template>
@@ -77,14 +88,13 @@ const navigateToConversation = (uuid) => {
params: {
uuid,
...(baseRoute === 'team-inbox-conversation' && { teamID: route.params.teamID }),
...(baseRoute === 'view-inbox-conversation' && { viewID: route.params.viewID }),
},
...(baseRoute === 'view-inbox-conversation' && { viewID: route.params.viewID })
}
})
}
const trimmedLastMessage = computed(() => {
const message = props.conversation.last_message || ''
return message.length > 100 ? message.slice(0, 100) + "..." : message
return message.length > 100 ? message.slice(0, 100) + '...' : message
})
</script>

View File

@@ -1,61 +1,62 @@
<template>
<div class="flex flex-col gap-1 mb-5">
<p class="font-medium">SLA policy</p>
<p v-if="conversation.sla_policy_name">
{{ conversation.sla_policy_name }}
</p>
<p v-else>-</p>
</div>
<div class="flex flex-col gap-1 mb-5">
<p class="font-medium">SLA policy</p>
<p v-if="conversation.sla_policy_name">
{{ conversation.sla_policy_name }}
</p>
<p v-else>-</p>
<div class="flex flex-col gap-1 mb-5">
<p class="font-medium">Reference number</p>
<p>
{{ conversation.reference_number }}
</p>
</div>
<div class="flex flex-col gap-1 mb-5">
<p class="font-medium">Initiated at</p>
<p>
{{ format(conversation.created_at, 'PPpp') }}
</p>
</div>
<div class="flex flex-col gap-1 mb-5">
<div class="flex justify-start items-center space-x-2">
<p class="font-medium">First reply at</p>
<SlaDisplay
:dueAt="conversation.first_reply_due_at"
:actualAt="conversation.first_reply_at"
/>
</div>
<p v-if="conversation.first_reply_at">
{{ format(conversation.first_reply_at, 'PPpp') }}
</p>
<p v-else>-</p>
</div>
<div class="flex flex-col gap-1 mb-5">
<p class="font-medium">Reference number</p>
<p>
{{ conversation.reference_number }}
</p>
</div>
<div class="flex flex-col gap-1 mb-5">
<p class="font-medium">Initiated at</p>
<p>
{{ format(conversation.created_at, 'PPpp') }}
</p>
</div>
<div class="flex flex-col gap-1 mb-5">
<div class="flex justify-start items-center space-x-2">
<p class="font-medium">First reply at</p>
<SlaDisplay :dueAt="conversation.first_reply_due_at" :actualAt="conversation.first_reply_at" />
</div>
<p v-if="conversation.first_reply_at">
{{ format(conversation.first_reply_at, 'PPpp') }}
</p>
<p v-else>-</p>
</div>
<div class="flex flex-col gap-1 mb-5">
<div class="flex justify-start items-center space-x-2">
<p class="font-medium">Resolved at</p>
<SlaDisplay :dueAt="conversation.resolution_due_at" :actualAt="conversation.resolved_at" />
</div>
<p v-if="conversation.resolved_at">
{{ format(conversation.resolved_at, 'PPpp') }}
</p>
<p v-else>-</p>
</div>
<div class="flex flex-col gap-1 mb-5">
<p class="font-medium">Closed at</p>
<p v-if="conversation.closed_at">
{{ format(conversation.closed_at, 'PPpp') }}
</p>
<p v-else>-</p>
<div class="flex flex-col gap-1 mb-5">
<div class="flex justify-start items-center space-x-2">
<p class="font-medium">Resolved at</p>
<SlaDisplay :dueAt="conversation.resolution_due_at" :actualAt="conversation.resolved_at" />
</div>
<p v-if="conversation.resolved_at">
{{ format(conversation.resolved_at, 'PPpp') }}
</p>
<p v-else>-</p>
</div>
<div class="flex flex-col gap-1 mb-5">
<p class="font-medium">Closed at</p>
<p v-if="conversation.closed_at">
{{ format(conversation.closed_at, 'PPpp') }}
</p>
<p v-else>-</p>
</div>
</template>
<script setup>
import { format } from 'date-fns'
import SlaDisplay from '@/components/sla/SlaDisplay.vue'
defineProps({
conversation: Object
conversation: Object
})
</script>

View File

@@ -3,7 +3,7 @@
<ConversationSideBarContact :conversation="conversationStore.current" class="p-3" />
<Accordion type="multiple" collapsible class="border-t" :default-value="[]">
<AccordionItem value="Actions">
<AccordionTrigger class="bg-muted p-3"> Actions </AccordionTrigger>
<AccordionTrigger class="bg-accent p-2"> Actions </AccordionTrigger>
<AccordionContent class="space-y-5 p-3">
<!-- Agent -->
<ComboBox
@@ -81,7 +81,7 @@
</AccordionContent>
</AccordionItem>
<AccordionItem value="Information">
<AccordionTrigger class="bg-muted p-3"> Information </AccordionTrigger>
<AccordionTrigger class="bg-accent p-2"> Information </AccordionTrigger>
<AccordionContent class="space-y-5 p-3">
<ConversationInfo :conversation="conversationStore.current"></ConversationInfo>
</AccordionContent>
@@ -123,10 +123,11 @@ onMounted(async () => {
watch(
() => conversationStore.current?.tags,
() => {
if (!conversationStore.current?.tags) return
conversationStore.upsertTags({
tags: JSON.stringify(conversationStore.current.tags)
})
},
}
)
const assignedUserID = computed(() => String(conversationStore.current.assigned_user_id))

View File

@@ -1,29 +1,36 @@
<template>
<div>
<Avatar class="size-20">
<AvatarImage :src="conversation?.contact?.avatar_url" v-if="conversation?.contact?.avatar_url" />
<AvatarFallback>
{{ conversation?.contact.first_name.toUpperCase().substring(0, 2) }}
</AvatarFallback>
</Avatar>
<h4 class="mt-3">
{{ conversation?.contact.first_name + ' ' + conversation?.contact.last_name }}
</h4>
<p class="text-sm text-muted-foreground flex gap-2 mt-1" v-if="conversation?.contact.email">
<Mail class="size-3 mt-1"></Mail>
{{ conversation.contact.email }}
</p>
<p class="text-sm text-muted-foreground flex gap-2 mt-1" v-if="conversation?.contact?.phone_number">
<Phone class="size-3 mt-1"></Phone>
{{ conversation.contact.phone_number }}
</p>
</div>
<div>
<Avatar class="size-20">
<AvatarImage
:src="conversation?.contact?.avatar_url"
v-if="conversation?.contact?.avatar_url"
/>
<AvatarFallback>
{{ conversation?.contact.first_name.toUpperCase().substring(0, 2) }}
</AvatarFallback>
</Avatar>
<h4 class="mt-3">
{{ conversation?.contact.first_name + ' ' + conversation?.contact.last_name }}
</h4>
<p class="text-sm text-muted-foreground flex gap-2 mt-1" v-if="conversation?.contact.email">
<Mail class="size-3 mt-1"></Mail>
{{ conversation.contact.email }}
</p>
<p class="text-sm text-muted-foreground flex gap-2 mt-1">
<Phone class="size-3 mt-1"></Phone>
<span>
{{
conversation?.contact?.phone_number ? conversation.contact.phone_number : 'Not available'
}}
</span>
</p>
</div>
</template>
<script setup>
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Mail, Phone } from 'lucide-vue-next'
defineProps({
conversation: Object
conversation: Object
})
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex flex-1 flex-col gap-x-5 box p-5 rounded-md space-y-5">
<div class="flex flex-1 flex-col gap-x-5 box p-5 rounded-md space-y-5 bg-white">
<div class="flex items-center space-x-2">
<p class="text-2xl">{{ title }}</p>
<div class="bg-green-100/70 flex items-center space-x-2 px-1 rounded">

View File

@@ -6,21 +6,29 @@
</p>
</div>
<div class="flex flex-row gap-2 justify-end">
<div class="flex flex-col message-bubble justify-end items-end relative !rounded-tr-none" :class="{
'!bg-[#FEF1E1]': message.private,
'bg-white': !message.private,
'opacity-50 animate-pulse': message.status === 'pending',
'bg-red': message.status === 'failed'
}">
<div
class="flex flex-col message-bubble justify-end items-end relative !rounded-tr-none"
:class="{
'!bg-[#FEF1E1]': message.private,
'bg-white': !message.private,
'opacity-50 animate-pulse': message.status === 'pending',
'bg-red': message.status === 'failed'
}"
>
<div v-html="messageContent" :class="{ 'mb-3': message.attachments.length > 0 }"></div>
<MessageAttachmentPreview :attachments="nonInlineAttachments" />
<Spinner v-if="message.status === 'pending'" />
<Spinner v-if="message.status === 'pending'" size="w-4 h-4" />
<!-- Icons -->
<div class="flex items-center space-x-2 mt-2">
<Lock :size="10" v-if="isPrivateMessage" />
<CheckCheck :size="14" v-if="showCheckCheck" />
<RotateCcw size="10" @click="retryMessage(message)" class="cursor-pointer" v-if="showRetry"></RotateCcw>
<RotateCcw
size="10"
@click="retryMessage(message)"
class="cursor-pointer"
v-if="showRetry"
></RotateCcw>
</div>
</div>
<Avatar class="cursor-pointer">
@@ -52,13 +60,12 @@ import { format } from 'date-fns'
import { useConversationStore } from '@/stores/conversation'
import { Lock } from 'lucide-vue-next'
import { revertCIDToImageSrc } from '@/utils/strings'
import api from '@/api'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Spinner } from '@/components/ui/spinner'
import { RotateCcw, CheckCheck } from 'lucide-vue-next'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import MessageAttachmentPreview from '@/components/attachment/MessageAttachmentPreview.vue'
import api from '@/api'
const props = defineProps({
message: Object
@@ -76,7 +83,7 @@ const getFullName = computed(() => {
})
const getAvatar = computed(() => {
return participant.value?.avatar_url || ""
return participant.value?.avatar_url || ''
})
const messageContent = computed(() => {
@@ -84,7 +91,7 @@ const messageContent = computed(() => {
})
const nonInlineAttachments = computed(() =>
props.message.attachments.filter(attachment => attachment.disposition !== 'inline')
props.message.attachments.filter((attachment) => attachment.disposition !== 'inline')
)
const isPrivateMessage = computed(() => {

View File

@@ -47,7 +47,6 @@
import { computed, ref } from 'vue'
import { format } from 'date-fns'
import { useConversationStore } from '@/stores/conversation'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Letter } from 'vue-letter'
@@ -61,7 +60,7 @@ const convStore = useConversationStore()
const showQuotedText = ref(false)
const getAvatar = computed(() => {
return convStore.current.avatar_url || ''
return convStore.current?.avatar_url || ''
})
const sanitizedMessageContent = computed(() => {
@@ -83,12 +82,12 @@ const nonInlineAttachments = computed(() =>
)
const getFullName = computed(() => {
const contact = convStore.current.contact || {}
const contact = convStore.current?.contact || {}
return `${contact.first_name || ''} ${contact.last_name || ''}`.trim()
})
const avatarFallback = computed(() => {
const contact = convStore.current.contact || {}
const contact = convStore.current?.contact || {}
return (contact.first_name || '').toUpperCase().substring(0, 2)
})
</script>

View File

@@ -1,44 +1,74 @@
<template>
<div ref="threadEl" class="overflow-y-scroll relative h-full" @scroll="handleScroll">
<div class="min-h-full relative pb-20">
<div class="text-center mt-3" v-if="conversationStore.messages.hasMore && !conversationStore.messages.loading">
<Button variant="ghost" @click="conversationStore.fetchNextMessages">
<RefreshCw size="17" class="mr-2" />
Load more
</Button>
</div>
<div v-for="message in conversationStore.conversationMessages" :key="message.uuid"
:class="message.type === 'activity' ? 'm-4' : 'm-6'">
<div v-if="conversationStore.messages.loading">
<MessagesSkeleton></MessagesSkeleton>
<div class="flex flex-col relative h-full">
<div ref="threadEl" class="flex-1 overflow-y-auto" @scroll="handleScroll">
<div class="min-h-full pb-20 px-4">
<DotLoader v-if="conversationStore.messages.loading" />
<div
class="text-center mt-3"
v-if="conversationStore.messages.hasMore && !conversationStore.messages.loading"
>
<Button
size="sm"
variant="outline"
@click="conversationStore.fetchNextMessages"
class="transition-all duration-200 hover:bg-gray-100 dark:hover:bg-gray-700 hover:scale-105 active:scale-95"
>
<RefreshCw size="17" class="mr-2" />
Load more
</Button>
</div>
<div v-else>
<div v-if="!message.private">
<ContactMessageBubble :message="message" v-if="message.type === 'incoming'" />
<AgentMessageBubble :message="message" v-if="message.type === 'outgoing'" />
<TransitionGroup
enter-active-class="animate-slide-in"
leave-active-class="animate-slide-out"
tag="div"
class="space-y-4"
>
<div
v-for="message in conversationStore.conversationMessages"
:key="message.uuid"
:class="message.type === 'activity' ? 'my-2' : 'my-4'"
>
<div v-if="!message.private">
<ContactMessageBubble :message="message" v-if="message.type === 'incoming'" />
<AgentMessageBubble :message="message" v-if="message.type === 'outgoing'" />
</div>
<div v-else-if="isPrivateNote(message)">
<AgentMessageBubble :message="message" v-if="message.type === 'outgoing'" />
</div>
<div v-else-if="message.type === 'activity'">
<ActivityMessageBubble :message="message" />
</div>
</div>
<div v-else-if="isPrivateNote(message)">
<AgentMessageBubble :message="message" v-if="message.type === 'outgoing'" />
</div>
<div v-else-if="message.type === 'activity'">
<ActivityMessageBubble :message="message" />
</div>
</div>
</TransitionGroup>
</div>
</div>
<!-- Sticky container for the scroll arrow -->
<div v-show="!isAtBottom" class="sticky bottom-6 flex justify-end px-6">
<div class="relative">
<button @click="handleScrollToBottom" class="w-8 h-8 rounded-full flex items-center justify-center shadow">
<ArrowDown size="20" />
<Transition
enter-active-class="transition ease-out duration-200"
enter-from-class="opacity-0 translate-y-1"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition ease-in duration-150"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-1"
>
<div v-show="!isAtBottom" class="absolute bottom-6 right-6 z-10">
<button
@click="handleScrollToBottom"
class="w-10 h-10 rounded-full flex items-center justify-center shadow-lg border bg-white text-primary transition-colors duration-200 hover:bg-gray-100"
>
<ChevronDown size="18" />
</button>
<span v-if="unReadMessages > 0"
class="absolute -top-1 -right-1 min-w-[20px] h-5 px-1.5 rounded-full bg-primary text-white text-xs font-medium flex items-center justify-center">
<span
v-if="unReadMessages > 0"
class="absolute -top-1 -right-1 min-w-[20px] h-5 px-1.5 rounded-full bg-green-500 text-secondary text-xs font-medium flex items-center justify-center"
>
{{ unReadMessages }}
</span>
</div>
</div>
</Transition>
</div>
</template>
@@ -47,11 +77,11 @@ import { ref, onMounted, watch } from 'vue'
import ContactMessageBubble from './ContactMessageBubble.vue'
import ActivityMessageBubble from './ActivityMessageBubble.vue'
import AgentMessageBubble from './AgentMessageBubble.vue'
import MessagesSkeleton from './MessagesSkeleton.vue'
import { DotLoader } from '@/components/ui/loader'
import { useConversationStore } from '@/stores/conversation'
import { useUserStore } from '@/stores/user'
import { Button } from '@/components/ui/button'
import { RefreshCw, ArrowDown } from 'lucide-vue-next'
import { RefreshCw, ChevronDown } from 'lucide-vue-next'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents'
@@ -61,6 +91,7 @@ const threadEl = ref(null)
const emitter = useEmitter()
const isAtBottom = ref(true)
const unReadMessages = ref(0)
const currentConversationUUID = ref('')
const checkIfAtBottom = () => {
const thread = threadEl.value
@@ -82,7 +113,6 @@ const handleScrollToBottom = () => {
const scrollToBottom = () => {
setTimeout(() => {
console.log('scrolling..')
const thread = threadEl.value
if (thread) {
thread.scrollTop = thread.scrollHeight
@@ -92,8 +122,11 @@ const scrollToBottom = () => {
}
onMounted(() => {
scrollToBottom()
checkIfAtBottom()
handleNewMessage()
})
const handleNewMessage = () => {
emitter.on(EMITTER_EVENTS.NEW_MESSAGE, (data) => {
if (data.conversation_uuid === conversationStore.current.uuid) {
if (data.message.sender_id === userStore.userID) {
@@ -103,18 +136,22 @@ onMounted(() => {
}
}
})
})
}
// On conversation change scroll to the bottom
watch(
() => conversationStore.current.uuid,
() => {
unReadMessages.value = 0
scrollToBottom()
() => conversationStore.conversationMessages,
(messages) => {
// Scroll to bottom when conversation changes and there are new messages.
// New messages on next db page should not scroll to bottom.
if (messages.length > 0 && currentConversationUUID.value !== conversationStore.current?.uuid) {
currentConversationUUID.value = conversationStore.current.uuid
unReadMessages.value = 0
scrollToBottom()
}
}
)
const isPrivateNote = (message) => {
return message.type === 'outgoing' && message.private
}
</script>
</script>

View File

@@ -0,0 +1,31 @@
<template>
<div class="flex items-center space-x-4 p-4">
<SidebarTrigger class="cursor-pointer w-4 h-4 text-black" />
<div class="flex-1 flex items-center">
<Search class="w-5 h-5" />
<Separator orientation="vertical" />
<Input
v-model="model"
placeholder="Search"
class="w-full border-none shadow-none focus:ring-0 focus:ring-offset-0"
/>
</div>
</div>
<Separator />
</template>
<script setup>
import { Separator } from '@/components/ui/separator'
import { Input } from '@/components/ui/input'
import { SidebarTrigger } from '@/components/ui/sidebar'
import { Search } from 'lucide-vue-next'
const model = defineModel(() => '')
</script>
<style scoped>
.focus\:ring-0:focus {
--tw-ring-offset-shadow: none;
--tw-ring-shadow: none;
box-shadow: none;
}
</style>

View File

@@ -0,0 +1,110 @@
<template>
<div class="max-w-5xl mx-auto p-6 bg-background min-h-screen">
<div v-if="isEmptyResults" class="text-center py-16 rounded-lg">
<SearchXIcon class="h-20 w-20 text-muted-foreground mx-auto mb-6" />
<h2 class="text-2xl font-bold text-foreground mb-3">No results found</h2>
<p class="text-muted-foreground text-lg max-w-md mx-auto">
We couldn't find any matches. Try adjusting your search query.
</p>
</div>
<div v-else class="space-y-8">
<div
v-for="(items, type) in results"
:key="type"
class="bg-card rounded-lg shadow-md overflow-hidden"
>
<h2 class="bg-primary text-lg font-bold text-secondary py-2 px-6 capitalize">
{{ type }}
</h2>
<div v-if="items.length === 0" class="p-6 text-muted-foreground">
No {{ type }} found
</div>
<div class="divide-y divide-border">
<div
v-for="item in items"
:key="item.id || item.uuid"
class="p-6 hover:bg-accent transition duration-300 ease-in-out group"
>
<router-link
:to="{
name: 'inbox-conversation',
params: {
uuid: type === 'conversations' ? item.uuid : item.conversation_uuid,
type: 'assigned'
}
}"
class="block"
>
<div class="flex justify-between items-start">
<div class="flex-grow">
<div
class="text-sm font-semibold text-primary mb-2 group-hover:text-primary transition duration-300"
>
#{{
type === 'conversations'
? item.reference_number
: item.conversation_reference_number
}}
</div>
<div
class="text-card-foreground font-medium mb-2 text-lg group-hover:text-foreground transition duration-300"
>
{{
truncateText(type === 'conversations' ? item.subject : item.text_content, 100)
}}
</div>
<div class="text-sm text-muted-foreground flex items-center">
<ClockIcon class="h-4 w-4 mr-1" />
{{
formatDate(
type === 'conversations' ? item.created_at : item.conversation_created_at
)
}}
</div>
</div>
<div
class="bg-secondary rounded-full p-2 group-hover:bg-primary transition duration-300"
>
<ChevronRightIcon
class="h-5 w-5 text-secondary-foreground group-hover:text-primary-foreground"
aria-hidden="true"
/>
</div>
</div>
</router-link>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { SearchXIcon, ChevronRightIcon, ClockIcon } from 'lucide-vue-next'
import { format, parseISO } from 'date-fns'
const props = defineProps({
results: {
type: Object,
required: true
}
})
const isEmptyResults = computed(() => {
return Object.values(props.results).every((arr) => arr.length === 0)
})
const formatDate = (dateString) => {
const date = parseISO(dateString)
return format(date, 'MMM d, yyyy HH:mm')
}
const truncateText = (text, length) => {
if (!text) return ''
if (text.length <= length) return text
return text.slice(0, length) + '...'
}
</script>

View File

@@ -1,25 +1,15 @@
<script setup>
import { CONVERSATION_LIST_TYPE } from '@/constants/conversation'
import api from '@/api'
import { useStorage } from '@vueuse/core'
import { RouterLink, useRoute } from 'vue-router'
import SidebarNavUser from './SidebarNavUser.vue'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupLabel,
SidebarMenuBadge,
SidebarHeader,
SidebarInset,
SidebarMenuSkeleton,
SidebarMenu,
SidebarSeparator,
SidebarGroupContent,
@@ -27,50 +17,25 @@ import {
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarTrigger,
SidebarRail,
SidebarRail
} from '@/components/ui/sidebar'
import {
AudioWaveform,
BadgeCheck,
Bell,
Users,
Bot,
Inbox,
ChevronRight,
ChevronsUpDown,
Command,
CreditCard,
SlidersHorizontal,
Folder,
Shield,
FileLineChart,
EllipsisVertical,
MessageCircleHeart,
Plus,
MessageCircle,
Search,
MessageCircle
} from 'lucide-vue-next'
import { computed } from 'vue'
import { useUserStore } from '@/stores/user'
import { ref, onMounted, reactive, computed } from 'vue'
import { handleHTTPError } from '@/utils/http'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useConversationStore } from '@/stores/conversation'
import ConversationSideBar from '@/components/conversation/sidebar/ConversationSideBar.vue'
const route = useRoute()
@@ -78,10 +43,10 @@ const route = useRoute()
defineProps({
isLoading: Boolean,
open: Boolean,
activeItem: { type: Object, default: () => { } },
activeGroup: { type: Object, default: () => { } },
activeItem: { type: Object, default: () => {} },
activeGroup: { type: Object, default: () => {} },
userTeams: { type: Array, default: () => [] },
userViews: { type: Array, default: () => [] },
userViews: { type: Array, default: () => [] }
})
const conversationStore = useConversationStore()
@@ -103,26 +68,31 @@ const deleteView = (view) => {
const filterNavItemsByPermissions = (navItems, userStore) => {
return navItems
.filter(item => {
.filter((item) => {
// Check if the item has permissions and if all are satisfied
const hasPermission = !item.permissions || item.permissions.every(permission => userStore.can(permission))
const hasPermission =
!item.permissions || item.permissions.every((permission) => userStore.can(permission))
// Check if the children have permissions
const filteredChildren = item.children
? item.children.filter(child =>
!child.permissions || child.permissions.every(permission => userStore.can(permission))
)
? item.children.filter(
(child) =>
!child.permissions ||
child.permissions.every((permission) => userStore.can(permission))
)
: []
// Include item only if it has permission and either no children or children with permission
return hasPermission && (filteredChildren.length > 0 || !item.children)
})
.map(item => ({
.map((item) => ({
...item,
children: item.children
? item.children.filter(child =>
!child.permissions || child.permissions.every(permission => userStore.can(permission))
)
? item.children.filter(
(child) =>
!child.permissions ||
child.permissions.every((permission) => userStore.can(permission))
)
: []
}))
}
@@ -134,8 +104,8 @@ const hasAdminAccess = computed(() => {
})
const filterReportsNavItemsByPermissions = (navItems, userStore) => {
return navItems.filter(item =>
!item.permissions || item.permissions.every(permission => userStore.can(permission))
return navItems.filter(
(item) => !item.permissions || item.permissions.every((permission) => userStore.can(permission))
)
}
@@ -143,20 +113,16 @@ const filteredReportsNavItems = computed(() =>
filterReportsNavItemsByPermissions(reportsNavItems, userStore)
)
const hasReportsAccess = computed(() =>
filteredReportsNavItems.value.length > 0
)
const hasReportsAccess = computed(() => filteredReportsNavItems.value.length > 0)
const reportsNavItems = [
{
title: 'Overview',
href: '/reports/overview',
permissions: ['reports:manage'],
},
permissions: ['reports:manage']
}
]
const adminNavItems = [
{
title: 'General',
@@ -167,9 +133,9 @@ const adminNavItems = [
title: 'General',
href: '/admin/general',
description: 'Configure general app settings',
permissions: ['general_settings:manage'],
permissions: ['general_settings:manage']
}
],
]
},
{
title: 'Conversations',
@@ -180,21 +146,21 @@ const adminNavItems = [
title: 'Tags',
href: '/admin/conversations/tags',
description: 'Manage conversation tags.',
permissions: ['tags:manage'],
permissions: ['tags:manage']
},
{
title: 'Macros',
href: '/admin/conversations/macros',
description: 'Manage macros.',
permissions: ['tags:manage'],
permissions: ['tags:manage']
},
{
title: 'Statuses',
href: '/admin/conversations/statuses',
description: 'Manage conversation statuses.',
permissions: ['tags:manage'],
},
],
permissions: ['tags:manage']
}
]
},
{
title: 'Inboxes',
@@ -205,9 +171,9 @@ const adminNavItems = [
title: 'Inboxes',
href: '/admin/inboxes',
description: 'Manage your inboxes',
permissions: ['tags:manage'],
},
],
permissions: ['tags:manage']
}
]
},
{
title: 'Teams',
@@ -218,21 +184,21 @@ const adminNavItems = [
title: 'Users',
href: '/admin/teams/users',
description: 'Manage users',
permissions: ['tags:manage'],
permissions: ['tags:manage']
},
{
title: 'Teams',
href: '/admin/teams/teams',
description: 'Manage teams',
permissions: ['tags:manage'],
permissions: ['tags:manage']
},
{
title: 'Roles',
href: '/admin/teams/roles',
description: 'Manage roles',
permissions: ['tags:manage'],
},
],
permissions: ['tags:manage']
}
]
},
{
title: 'Automations',
@@ -243,9 +209,9 @@ const adminNavItems = [
title: 'Automations',
href: '/admin/automations',
description: 'Manage automations',
permissions: ['tags:manage'],
},
],
permissions: ['tags:manage']
}
]
},
{
title: 'Email notifications',
@@ -256,9 +222,9 @@ const adminNavItems = [
title: 'Email notifications',
href: '/admin/notification',
description: 'Configure SMTP',
permissions: ['tags:manage'],
},
],
permissions: ['tags:manage']
}
]
},
{
title: 'Email templates',
@@ -269,9 +235,9 @@ const adminNavItems = [
title: 'Email templates',
href: '/admin/templates',
description: 'Manage email templates',
permissions: ['tags:manage'],
},
],
permissions: ['tags:manage']
}
]
},
{
title: 'Business hours',
@@ -282,9 +248,9 @@ const adminNavItems = [
title: 'Business hours',
href: '/admin/business-hours',
description: 'Manage business hours',
permissions: ['tags:manage'],
},
],
permissions: ['tags:manage']
}
]
},
{
title: 'SLA',
@@ -295,9 +261,9 @@ const adminNavItems = [
title: 'SLA',
href: '/admin/sla',
description: 'Manage SLA policies',
permissions: ['tags:manage'],
},
],
permissions: ['tags:manage']
}
]
},
{
title: 'SSO',
@@ -308,10 +274,10 @@ const adminNavItems = [
title: 'SSO',
href: '/admin/oidc',
description: 'Manage OpenID SSO configurations',
permissions: ['tags:manage'],
},
],
},
permissions: ['tags:manage']
}
]
}
]
const accountNavItems = [
@@ -331,23 +297,34 @@ const isInboxRoute = (path) => {
}
const hasConversationOpen = computed(() => {
return conversationStore.current
return conversationStore.current || conversationStore.conversation.loading
})
</script>
<template>
<div class="flex flex-row justify-between h-full">
<div class="flex-1">
<SidebarProvider :open="open" @update:open="($event) => emit('update:open', $event)" style="--sidebar-width: 16rem;">
<SidebarProvider
:open="open"
@update:open="($event) => emit('update:open', $event)"
style="--sidebar-width: 16rem"
>
<!-- Flex Container that holds all the sidebar components -->
<Sidebar collapsible="icon" class="overflow-hidden [&>[data-sidebar=sidebar]]:flex-row !border-r-0">
<Sidebar
collapsible="icon"
class="overflow-hidden [&>[data-sidebar=sidebar]]:flex-row !border-r-0"
>
<!-- Left Sidebar (Icon Sidebar) -->
<Sidebar collapsible="none" class="!w-[calc(var(--sidebar-width-icon)_+_1px)] border-r">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton :isActive="isActiveParent('#')" size="sm" asChild class="md:h-8 md:p-0">
<SidebarMenuButton
:isActive="isActiveParent('#')"
size="sm"
asChild
class="md:h-8 md:p-0"
>
<a href="#">
<div class="flex items-center justify-center w-full h-full">
<MessageCircle size="25" />
@@ -369,14 +346,20 @@ const hasConversationOpen = computed(() => {
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem v-if="hasAdminAccess">
<SidebarMenuButton :isActive="route.path && route.path.startsWith('/admin')" asChild>
<SidebarMenuButton
:isActive="route.path && route.path.startsWith('/admin')"
asChild
>
<router-link :to="{ name: 'admin' }">
<Shield class="w-5 h-5" />
</router-link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem v-if="hasReportsAccess">
<SidebarMenuButton :isActive="route.path && route.path.startsWith('/reports')" asChild>
<SidebarMenuButton
:isActive="route.path && route.path.startsWith('/reports')"
asChild
>
<router-link :to="{ name: 'reports' }">
<FileLineChart class="w-5 h-5" />
</router-link>
@@ -393,12 +376,20 @@ const hasConversationOpen = computed(() => {
<!-- Reports sidebar -->
<template
v-if="hasReportsAccess && route.matched.some(record => record.name && record.name.startsWith('reports'))">
<Sidebar collapsible="none" class="!border-r-0 bg-white ">
v-if="
hasReportsAccess &&
route.matched.some((record) => record.name && record.name.startsWith('reports'))
"
>
<Sidebar collapsible="none" class="!border-r-0 bg-white">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton :isActive="isActiveParent('/reports/overview')" asChild size="md">
<SidebarMenuButton
:isActive="isActiveParent('/reports/overview')"
asChild
size="md"
>
<div>
<span class="font-semibold text-2xl">Reports</span>
</div>
@@ -424,10 +415,11 @@ const hasConversationOpen = computed(() => {
</Sidebar>
</template>
<!-- Admin Sidebar -->
<template v-if="route.matched.some(record => record.name && record.name.startsWith('admin'))">
<Sidebar collapsible="none" class="!border-r-0 bg-white ">
<template
v-if="route.matched.some((record) => record.name && record.name.startsWith('admin'))"
>
<Sidebar collapsible="none" class="!border-r-0 bg-white">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
@@ -444,18 +436,27 @@ const hasConversationOpen = computed(() => {
<SidebarGroup>
<SidebarMenu>
<SidebarMenuItem v-for="item in filteredAdminNavItems" :key="item.title">
<SidebarMenuButton v-if="!item.children" :isActive="isActiveParent(item.href)" asChild>
<SidebarMenuButton
v-if="!item.children"
:isActive="isActiveParent(item.href)"
asChild
>
<router-link :to="item.href">
<span>{{ item.title }}</span>
</router-link>
</SidebarMenuButton>
<Collapsible v-else class="group/collapsible" :default-open="isActiveParent(item.href)">
<Collapsible
v-else
class="group/collapsible"
:default-open="isActiveParent(item.href)"
>
<CollapsibleTrigger as-child>
<SidebarMenuButton :isActive="isActiveParent(item.href)">
<span>{{ item.title }}</span>
<ChevronRight
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
/>
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
@@ -480,11 +481,15 @@ const hasConversationOpen = computed(() => {
<!-- Account sidebar -->
<template v-if="isActiveParent('/account')">
<Sidebar collapsible="none" class="!border-r-0 bg-white ">
<Sidebar collapsible="none" class="!border-r-0 bg-white">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton :isActive="isActiveParent('/account/profile')" asChild size="md">
<SidebarMenuButton
:isActive="isActiveParent('/account/profile')"
asChild
size="md"
>
<div>
<span class="font-semibold text-2xl">Account</span>
</div>
@@ -515,13 +520,21 @@ const hasConversationOpen = computed(() => {
<!-- Conversation Sidebar -->
<template v-if="route.path && isInboxRoute(route.path)">
<Sidebar collapsible="none" class="!border-r-0 bg-white ">
<Sidebar collapsible="none" class="!border-r-0 bg-white">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<div>
<span class="font-semibold text-2xl">Inbox</span>
<div class="flex items-center justify-between w-full">
<div class="font-semibold text-2xl">Inbox</div>
<div class="ml-auto">
<router-link :to="{ name: 'search' }">
<Search
class="transition-transform duration-200 hover:scale-110 cursor-pointer"
size="20"
/>
</router-link>
</div>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
@@ -529,7 +542,6 @@ const hasConversationOpen = computed(() => {
</SidebarHeader>
<SidebarSeparator />
<SidebarContent>
<SidebarGroup>
<SidebarMenu>
<!-- Inboxes Collapsible -->
@@ -540,21 +552,30 @@ const hasConversationOpen = computed(() => {
<MessageCircle />
<span>Inboxes</span>
<ChevronRight
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
/>
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
<SidebarMenuSubItem>
<SidebarMenuButton :isActive="isActiveParent('/inboxes/assigned')" asChild>
<SidebarMenuButton
:isActive="isActiveParent('/inboxes/assigned')"
asChild
>
<router-link :to="{ name: 'inbox', params: { type: 'assigned' } }">
<span>My inbox</span>
</router-link>
</SidebarMenuButton>
</SidebarMenuSubItem>
<SidebarMenuSubItem>
<SidebarMenuButton :isActive="isActiveParent('/inboxes/unassigned')" asChild>
<router-link :to="{ name: 'inbox', params: { type: 'unassigned' } }">
<SidebarMenuButton
:isActive="isActiveParent('/inboxes/unassigned')"
asChild
>
<router-link
:to="{ name: 'inbox', params: { type: 'unassigned' } }"
>
<span>Unassigned</span>
</router-link>
</SidebarMenuButton>
@@ -580,15 +601,21 @@ const hasConversationOpen = computed(() => {
<Users />
<span>Team inboxes</span>
<ChevronRight
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
/>
</router-link>
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
<SidebarMenuSubItem v-for="team in userTeams" :key="team.id">
<SidebarMenuButton :isActive="isActiveParent(`/teams/${team.id}`)" asChild>
<router-link :to="{ name: 'team-inbox', params: { teamID: team.id } }">
<SidebarMenuButton
:isActive="isActiveParent(`/teams/${team.id}`)"
asChild
>
<router-link
:to="{ name: 'team-inbox', params: { teamID: team.id } }"
>
{{ team.emoji }}<span>{{ team.name }}</span>
</router-link>
</SidebarMenuButton>
@@ -607,10 +634,11 @@ const hasConversationOpen = computed(() => {
<SlidersHorizontal />
<span>Views</span>
<div>
<Plus size="18" @click.stop="openCreateViewDialog" class="rounded-lg cursor-pointer opacity-0 transition-all duration-200
group-hover:opacity-100 hover:bg-gray-200 hover:shadow-sm
text-gray-600 hover:text-gray-800 transform hover:scale-105
active:scale-100 p-1" />
<Plus
size="18"
@click.stop="openCreateViewDialog"
class="rounded-lg cursor-pointer opacity-0 transition-all duration-200 group-hover:opacity-100 hover:bg-gray-200 hover:shadow-sm text-gray-600 hover:text-gray-800 transform hover:scale-105 active:scale-100 p-1"
/>
</div>
</router-link>
</SidebarMenuButton>
@@ -618,13 +646,19 @@ const hasConversationOpen = computed(() => {
<SidebarMenuAction>
<ChevronRight
class="transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
v-if="userViews.length" />
v-if="userViews.length"
/>
</SidebarMenuAction>
<CollapsibleContent>
<SidebarMenuSub>
<SidebarMenuSubItem v-for="view in userViews" :key="view.id">
<SidebarMenuButton :isActive="isActiveParent(`/views/${view.id}`)" asChild>
<router-link :to="{ name: 'view-inbox', params: { viewID: view.id } }">
<SidebarMenuButton
:isActive="isActiveParent(`/views/${view.id}`)"
asChild
>
<router-link
:to="{ name: 'view-inbox', params: { viewID: view.id } }"
>
<span>{{ view.name }}</span>
</router-link>
</SidebarMenuButton>
@@ -660,16 +694,15 @@ const hasConversationOpen = computed(() => {
<slot></slot>
</SidebarInset>
</SidebarProvider>
</div>
<!-- Right Sidebar with conversation details -->
<div v-if="hasConversationOpen">
<SidebarProvider :open="true" style="--sidebar-width: 20rem;">
<SidebarProvider :open="true" style="--sidebar-width: 20rem">
<Sidebar collapsible="none" side="right">
<SidebarSeparator />
<SidebarContent>
<SidebarGroup style="padding: 0;">
<SidebarGroup style="padding: 0">
<SidebarMenu>
<SidebarMenuItem>
<ConversationSideBar />

View File

@@ -1,21 +1,21 @@
<template>
<div v-if="dueAt" class="flex items-center justify-center">
<TransitionGroup name="fade" class="animate-fade-in-down">
<TransitionGroup name="fade">
<span
v-if="actualAt && isAfterDueTime"
v-if="sla?.status === 'overdue'"
key="overdue"
class="inline-flex items-center bg-red-50 px-1 py-1 rounded-full text-xs font-medium text-red-700 border border-red-200 shadow-sm transition-all duration-300 ease-in-out hover:bg-red-100 animate-fade-in-down min-w-[90px]"
>
<AlertCircle class="w-3 h-3 flex-shrink-0" />
<AlertCircle class="w-3 h-3 flex-shrink-0 mr-1" />
<span class="flex-1 text-center">{{ label }} Overdue</span>
</span>
<span
v-else-if="actualAt && !isAfterDueTime && showSLAHit"
v-else-if="sla?.status === 'hit' && showSLAHit"
key="sla-hit"
class="inline-flex items-center bg-green-50 px-1 py-1 rounded-full text-xs font-medium text-green-700 border border-green-200 shadow-sm transition-all duration-300 ease-in-out hover:bg-green-100 animate-fade-in-down min-w-[90px]"
>
<CheckCircle class="w-3 h-3 flex-shrink-0" />
<CheckCircle class="w-3 h-3 flex-shrink-0" />
<span class="flex-1 text-center">{{ label }} SLA Hit</span>
</span>
@@ -24,18 +24,9 @@
key="remaining"
class="inline-flex items-center bg-yellow-50 px-1 py-1 rounded-full text-xs font-medium text-yellow-700 border border-yellow-200 shadow-sm transition-all duration-300 ease-in-out hover:bg-yellow-100 animate-fade-in-down min-w-[90px]"
>
<Clock class="w-3 h-3 flex-shrink-0" />
<Clock class="w-3 h-3 flex-shrink-0" />
<span class="flex-1 text-center">{{ label }} {{ sla.value }}</span>
</span>
<span
v-else-if="sla?.status === 'overdue'"
key="sla-overdue"
class="inline-flex items-center bg-red-50 px-1 py-1 rounded-full text-xs font-medium text-red-700 border border-red-200 shadow-sm transition-all duration-300 ease-in-out hover:bg-red-100 animate-fade-in-down min-w-[90px]"
>
<AlertCircle class="w-3 h-3 flex-shrink-0" />
<span class="flex-1 text-center">{{ label }} overdue</span>
</span>
</TransitionGroup>
</div>
</template>
@@ -54,6 +45,5 @@ const props = defineProps({
default: true
}
})
const { sla, isAfterDueTime } = useSla(ref(props.dueAt), ref(props.actualAt))
const { sla } = useSla(ref(props.dueAt), ref(props.actualAt))
</script>

View File

@@ -0,0 +1,50 @@
<template>
<div class="flex flex-col items-center justify-center py-8 text-gray-600 dark:text-gray-300">
<div class="dot-spinner mb-4">
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
</div>
<p class="text-sm font-medium">Loading...</p>
</div>
</template>
<style scoped>
.dot-spinner {
display: flex;
justify-content: center;
align-items: center;
}
.dot {
width: 10px;
height: 10px;
margin: 0 5px;
background-color: currentColor;
border-radius: 50%;
display: inline-block;
animation: dot-flashing 1s infinite alternate;
}
.dot:nth-child(2) {
animation-delay: 0.2s;
}
.dot:nth-child(3) {
animation-delay: 0.4s;
}
.dot:nth-child(4) {
animation-delay: 0.6s;
}
@keyframes dot-flashing {
0% {
opacity: 0.2;
}
100% {
opacity: 1;
}
}
</style>

View File

@@ -1,20 +0,0 @@
<script setup lang="ts">
import { LoaderCircle } from 'lucide-vue-next'
defineProps({
loading: {
type: Boolean,
default: false
},
text: {
type: String,
default: 'Loading'
}
})
</script>
<template>
<div v-if="loading" class="flex">
<LoaderCircle class="animate-spin size-6 mr-2" /> {{ text }}
</div>
</template>

View File

@@ -1 +1 @@
export { default as Loader } from './Loader.vue'
export { default as DotLoader } from './DotLoader.vue'

View File

@@ -1,12 +1,20 @@
<template>
<div role="status" class="absolute -translate-x-1/2 -translate-y-1/2 top-2/4 left-1/2">
<svg :class="spinnerClass" aria-hidden="true" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path :class="fillColor" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
<div role="status" class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2">
<svg :class="spinnerClass" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
:class="['opacity-75', fillColor]"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span class="sr-only">Loading...</span>
</div>
</template>
@@ -16,17 +24,17 @@ import { computed } from 'vue'
const props = defineProps({
size: {
type: String,
default: 'w-6 h-6'
default: 'w-8 h-8'
},
color: {
type: String,
default: 'text-gray-200'
default: 'text-secondary'
},
fillColor: {
type: String,
default: 'fill-black'
default: 'fill-primary'
}
})
const spinnerClass = computed(() => `${props.size} ${props.color} animate-spin`)
</script>
const spinnerClass = computed(() => `${props.size} ${props.color} animate-spin animate-bounce-in`)
</script>

View File

@@ -1,22 +1,17 @@
// composables/useSla.js
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { isAfter } from 'date-fns'
import { ref, onMounted, onUnmounted } from 'vue'
import { calculateSla } from '@/utils/sla'
export function useSla (dueAt, actualAt) {
const sla = ref(null)
const isAfterDueTime = computed(() => {
if (!dueAt.value || !actualAt.value) return false
return isAfter(new Date(actualAt.value), new Date(dueAt.value))
})
function updateSla () {
if (!dueAt.value) {
sla.value = null
return
}
sla.value = calculateSla(dueAt.value)
sla.value = calculateSla(dueAt.value, actualAt.value)
}
onMounted(() => {
@@ -28,5 +23,5 @@ export function useSla (dueAt, actualAt) {
})
})
return { sla, isAfterDueTime, updateSla }
return { sla, updateSla }
}

View File

@@ -12,12 +12,6 @@ export const CONVERSATION_VIEWS_INBOXES = {
'all': 'All',
}
export const CONVERSATION_WS_ACTIONS = {
SUB_LIST: 'conversations_list_sub',
SET_CURRENT: 'conversation_set_current',
UNSET_CURRENT: 'conversation_unset_current'
}
export const CONVERSATION_DEFAULT_STATUSES = {
OPEN: 'Open',
PENDING: 'Pending',

View File

@@ -0,0 +1,6 @@
export const WS_EVENT = {
NEW_MESSAGE: 'new_message',
MESSAGE_PROP_UPDATE: 'message_prop_update',
CONVERSATION_PROP_UPDATE: 'conversation_prop_update',
}

View File

@@ -3,6 +3,7 @@ import App from '../App.vue'
import OuterApp from '../OuterApp.vue'
import DashboardView from '../views/DashboardView.vue'
import ConversationsView from '../views/ConversationView.vue'
import SearchView from '../views/SearchView.vue'
import UserLoginView from '../views/UserLoginView.vue'
import AccountView from '@/views/AccountView.vue'
import AdminView from '@/views/AdminView.vue'
@@ -57,6 +58,12 @@ const routes = [
redirect: '/inboxes/assigned',
meta: { title: 'Inbox', hidePageHeader: true },
children: [
{
path: 'search',
name: 'search',
component: SearchView,
meta: { title: 'Search', hidePageHeader: true },
},
{
path: ':type(assigned|unassigned|all)',
name: 'inbox',
@@ -386,7 +393,7 @@ const router = createRouter({
})
router.beforeEach((to, from, next) => {
document.title = to.meta.title || ''
document.title = to.meta.title + ' | LibreDesk'
next()
})

View File

@@ -4,11 +4,10 @@ import { CONVERSATION_LIST_TYPE, CONVERSATION_DEFAULT_STATUSES } from '@/constan
import { handleHTTPError } from '@/utils/http'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents'
import { subscribeConversationsList } from '@/websocket'
import api from '@/api'
export const useConversationStore = defineStore('conversation', () => {
const MAX_CONV_LIST_PAGE_SIZE = 30
const MAX_CONV_LIST_PAGE_SIZE = 100
const MAX_MESSAGE_LIST_PAGE_SIZE = 30
const priorities = ref([])
const statuses = ref([])
@@ -79,6 +78,7 @@ export const useConversationStore = defineStore('conversation', () => {
loading: false,
page: 1,
hasMore: false,
total: 0,
errorMessage: ''
})
@@ -186,7 +186,16 @@ export const useConversationStore = defineStore('conversation', () => {
const conversationsList = computed(() => {
if (!conversations.data) return []
return conversations.data
return [...conversations.data].sort((a, b) => {
const field = sortFieldMap[conversations.sortField]?.field
if (!a[field] && !b[field]) return 0
if (!a[field]) return 1 // null goes last
if (!b[field]) return -1
const order = sortFieldMap[conversations.sortField]?.order
return order === 'asc'
? new Date(a[field]) - new Date(b[field])
: new Date(b[field]) - new Date(a[field])
})
})
const conversationMessages = computed(() => {
@@ -197,7 +206,9 @@ export const useConversationStore = defineStore('conversation', () => {
function markConversationAsRead (uuid) {
const index = conversations.data.findIndex(conv => conv.uuid === uuid)
if (index !== -1) {
conversations.data[index].unread_message_count = 0
setTimeout(() => {
conversations.data[index].unread_message_count = 0
}, 3000)
}
}
@@ -217,8 +228,8 @@ export const useConversationStore = defineStore('conversation', () => {
})
async function fetchConversation (uuid) {
conversation.loading = true
resetCurrentConversation()
conversation.loading = true
try {
const resp = await api.getConversation(uuid)
conversation.data = resp.data.data
@@ -310,14 +321,18 @@ export const useConversationStore = defineStore('conversation', () => {
function fetchNextConversations () {
conversations.page++
fetchConversationsList(true, conversations.listType, conversations.teamID, conversations.listFilters)
fetchConversationsList(true, conversations.listType, conversations.teamID, conversations.listFilters, conversations.viewID, conversations.page)
}
function reFetchConversationsList (showLoader = true) {
fetchConversationsList(showLoader, conversations.listType, conversations.teamID, conversations.listFilters, conversations.viewID)
fetchConversationsList(showLoader, conversations.listType, conversations.teamID, conversations.listFilters, conversations.viewID, conversations.page)
}
async function fetchConversationsList (showLoader = true, listType = null, teamID = 0, filters = [], viewID = 0) {
async function fetchFirstPageConversations () {
await fetchConversationsList(false, conversations.listType, conversations.teamID, conversations.listFilters, conversations.viewID, 1)
}
async function fetchConversationsList (showLoader = true, listType = null, teamID = 0, filters = [], viewID = 0, page = 0) {
if (!listType) return
if (conversations.listType !== listType || conversations.teamID !== teamID || conversations.viewID !== viewID) {
resetConversations()
@@ -335,11 +350,12 @@ export const useConversationStore = defineStore('conversation', () => {
})
}
if (filters) conversations.listFilters = filters
subscribeConversationsList(listType, teamID)
if (showLoader) conversations.loading = true
try {
conversations.errorMessage = ''
const response = await makeConversationListRequest(listType, teamID, viewID, filters)
if (page === 0)
page = conversations.page
const response = await makeConversationListRequest(listType, teamID, viewID, filters, page)
processConversationListResponse(response)
} catch (error) {
conversations.errorMessage = handleHTTPError(error).message
@@ -348,12 +364,12 @@ export const useConversationStore = defineStore('conversation', () => {
}
}
async function makeConversationListRequest (listType, teamID, viewID, filters) {
async function makeConversationListRequest (listType, teamID, viewID, filters, page) {
filters = filters.length > 0 ? JSON.stringify(filters) : []
switch (listType) {
case CONVERSATION_LIST_TYPE.ASSIGNED:
return await api.getAssignedConversations({
page: conversations.page,
page: page,
page_size: MAX_CONV_LIST_PAGE_SIZE,
order_by: sortFieldMap[conversations.sortField].field,
order: sortFieldMap[conversations.sortField].order,
@@ -361,7 +377,7 @@ export const useConversationStore = defineStore('conversation', () => {
})
case CONVERSATION_LIST_TYPE.UNASSIGNED:
return await api.getUnassignedConversations({
page: conversations.page,
page: page,
page_size: MAX_CONV_LIST_PAGE_SIZE,
order_by: sortFieldMap[conversations.sortField].field,
order: sortFieldMap[conversations.sortField].order,
@@ -369,7 +385,7 @@ export const useConversationStore = defineStore('conversation', () => {
})
case CONVERSATION_LIST_TYPE.ALL:
return await api.getAllConversations({
page: conversations.page,
page: page,
page_size: MAX_CONV_LIST_PAGE_SIZE,
order_by: sortFieldMap[conversations.sortField].field,
order: sortFieldMap[conversations.sortField].order,
@@ -377,14 +393,14 @@ export const useConversationStore = defineStore('conversation', () => {
})
case CONVERSATION_LIST_TYPE.TEAM_UNASSIGNED:
return await api.getTeamUnassignedConversations(teamID, {
page: conversations.page,
page: page,
page_size: MAX_CONV_LIST_PAGE_SIZE,
order_by: sortFieldMap[conversations.sortField].field,
order: sortFieldMap[conversations.sortField].order
})
case CONVERSATION_LIST_TYPE.VIEW:
return await api.getViewConversations(viewID, {
page: conversations.page,
page: page,
page_size: MAX_CONV_LIST_PAGE_SIZE,
order_by: sortFieldMap[conversations.sortField].field,
order: sortFieldMap[conversations.sortField].order
@@ -407,6 +423,7 @@ export const useConversationStore = defineStore('conversation', () => {
else conversations.hasMore = true
if (!conversations.data) conversations.data = []
conversations.data.push(...newConversations)
conversations.total = apiResponse.total
}
async function updatePriority (v) {
@@ -488,7 +505,7 @@ export const useConversationStore = defineStore('conversation', () => {
return conversations.data?.find(c => c.uuid === uuid) ? true : false
}
function updateConversationLastMessage (message) {
function updateConversationList (message) {
const listConversation = conversations.data.find(c => c.uuid === message.conversation_uuid)
if (listConversation) {
listConversation.last_message = message.content
@@ -496,10 +513,12 @@ export const useConversationStore = defineStore('conversation', () => {
if (listConversation.uuid !== conversation?.data?.uuid) {
listConversation.unread_message_count += 1
}
} else {
fetchFirstPageConversations()
}
}
async function updateConversationMessageList (message) {
async function updateConversationMessage (message) {
if (conversation?.data?.uuid === message.conversation_uuid) {
if (!messages.data.some(msg => msg.uuid === message.uuid)) {
fetchParticipants(message.conversation_uuid)
@@ -510,14 +529,15 @@ export const useConversationStore = defineStore('conversation', () => {
conversation_uuid: message.conversation_uuid,
message: fetchedMessage
})
}, 50)
}, 100)
}
}
}
function addNewConversation (conversation) {
if (!conversationUUIDExists(conversation.uuid)) {
conversations.data.push(conversation)
// Fetch list of conversations again.
fetchFirstPageConversations()
}
}
@@ -584,19 +604,19 @@ export const useConversationStore = defineStore('conversation', () => {
fetchNextConversations,
updateMessageProp,
updateAssigneeLastSeen,
updateConversationMessageList,
updateConversationMessage,
snoozeConversation,
fetchConversation,
fetchConversationsList,
fetchMessages,
upsertTags,
reFetchConversationsList,
updateAssignee,
updatePriority,
updateStatus,
updateConversationLastMessage,
updateConversationList,
resetMessages,
resetCurrentConversation,
fetchFirstPageConversations,
fetchStatuses,
fetchPriorities,
setListSortField,

View File

@@ -1,13 +1,9 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { handleHTTPError } from '@/utils/http'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents'
import api from '@/api'
export const useSlaStore = defineStore('sla', () => {
const slas = ref([])
const emitter = useEmitter()
const options = computed(() => slas.value.map(sla => ({
label: sla.name,
value: String(sla.id)
@@ -18,11 +14,7 @@ export const useSlaStore = defineStore('sla', () => {
const response = await api.getAllSLAs()
slas.value = response?.data?.data || []
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
console.error(error)
}
}
return {

View File

@@ -4,37 +4,42 @@ import { differenceInMinutes } from 'date-fns'
* Calculates the SLA (Service Level Agreement) status based on the due date.
*
* @param {string} dueAt - The due date and time in ISO format.
* @param {string} actualAt - The actual date and time in ISO format.
* @returns {Object} An object containing the SLA status and the remaining or overdue time.
* @returns {string} return.status - The SLA status, either 'remaining' or 'overdue'.
* @returns {string} return.value - The remaining or overdue time in minutes, hours, or days.
*/
export function calculateSla (dueAt) {
const now = new Date()
export function calculateSla (dueAt, actualAt) {
const compareTime = actualAt ? new Date(actualAt) : new Date()
const dueTime = new Date(dueAt)
const diffInMinutes = differenceInMinutes(dueTime, now)
const diffInMinutes = differenceInMinutes(dueTime, compareTime)
if (diffInMinutes > 0) {
if (diffInMinutes >= 2880) {
if (!actualAt) {
if (diffInMinutes > 0) {
if (diffInMinutes >= 2880) {
return {
status: 'remaining',
value: `${Math.floor(diffInMinutes / 1440)} days`
}
}
return {
status: 'remaining',
value: `${Math.floor(diffInMinutes / 1440)} days`
value: diffInMinutes < 60 ? `${diffInMinutes} mins` : `${Math.floor(diffInMinutes / 60)} hrs`
}
}
return {
status: 'remaining',
value: diffInMinutes < 60 ? `${diffInMinutes} mins` : `${Math.floor(diffInMinutes / 60)} hrs`
}
}
const overdueTime = Math.abs(diffInMinutes)
if (overdueTime >= 2880) { // 48 hours * 60 minutes
const status = actualAt ? 'hit' : 'overdue'
if (overdueTime >= 2880) {
return {
status: 'overdue',
status,
value: `${Math.floor(overdueTime / 1440)} days`
}
}
return {
status: 'overdue',
status,
value: overdueTime < 60 ? `${overdueTime} mins` : `${Math.floor(overdueTime / 60)} hrs`
}
}
}

View File

@@ -1,11 +1,11 @@
<template>
<div class="flex">
<div class="border-r w-[380px]">
<div class="border-r w-[390px]">
<ConversationList />
</div>
<div class="border-r flex-1">
<Conversation v-if="conversationStore.current"></Conversation>
<ConversationPlaceholder v-else></ConversationPlaceholder>
<Conversation v-if="conversationStore.current || conversationStore.conversation.loading" />
<ConversationPlaceholder v-else/>
</div>
</div>
</template>
@@ -17,7 +17,6 @@ import Conversation from '@/components/conversation/Conversation.vue'
import ConversationPlaceholder from '@/components/conversation/ConversationPlaceholder.vue'
import { useConversationStore } from '@/stores/conversation'
import { CONVERSATION_LIST_TYPE, CONVERSATION_DEFAULT_STATUSES } from '@/constants/conversation'
import { unsetCurrentConversation, setCurrentConversation } from '@/websocket'
const props = defineProps({
uuid: String,
@@ -44,7 +43,6 @@ onMounted(() => {
})
onUnmounted(() => {
unsetCurrentConversation()
conversationStore.resetCurrentConversation()
conversationStore.resetMessages()
})
@@ -54,7 +52,6 @@ watch(
() => props.uuid,
(newUUID, oldUUID) => {
if (newUUID !== oldUUID && newUUID) {
unsetCurrentConversation()
fetchConversation(newUUID)
}
}
@@ -90,7 +87,6 @@ watch(
)
const fetchConversation = async (uuid) => {
setCurrentConversation(uuid)
await conversationStore.fetchConversation(uuid)
await conversationStore.fetchMessages(uuid)
await conversationStore.fetchParticipants(uuid)

View File

@@ -1,5 +1,5 @@
<template>
<div class="page-content">
<div class="page-content p-4">
<Spinner v-if="isLoading"></Spinner>
<div class="space-y-4">
<div class="text-sm text-gray-500 text-right">
@@ -9,10 +9,10 @@
<Card title="Open conversations" :counts="cardCounts" :labels="agentCountCardsLabels" />
<Card class="w-8/12" title="Agent status" :counts="sampleAgentStatusCounts" :labels="sampleAgentStatusLabels" />
</div>
<div class="dashboard-card p-5">
<div class="dashboard-card p-5 bg-white">
<LineChart :data="chartData.processedData"></LineChart>
</div>
<div class="dashboard-card p-5">
<div class="dashboard-card p-5 bg-white">
<BarChart :data="chartData.status_summary"></BarChart>
</div>
</div>

View File

@@ -1,35 +1,90 @@
<template>
<Spinner v-if="isLoading"></Spinner>
<div class="relative h-screen" id="reset-password-container" :class="{ 'soft-fade': isLoading }">
<div class="absolute left-1/2 top-20 transform -translate-x-1/2 w-96 h-1/2">
<form @submit.prevent="requestResetAction">
<Card>
<CardHeader class="space-y-1">
<CardTitle class="text-2xl text-center">Reset Password</CardTitle>
<p class="text-sm text-muted-foreground text-center">
Enter your email to receive a password reset link.
</p>
</CardHeader>
<CardContent class="grid gap-4">
<div class="grid gap-2">
<Label for="email">Email</Label>
<Input id="email" type="email" placeholder="Enter your email address"
v-model.trim="resetForm.email" :class="{ 'border-red-500': emailHasError }" />
</div>
</CardContent>
<CardFooter class="flex flex-col gap-5">
<Button class="w-full" @click.prevent="requestResetAction" :disabled="isLoading" type="submit">
Send Reset Link
</Button>
<Error :errorMessage="errorMessage" :border="true"></Error>
<div>
<router-link to="/" class="text-xs">Back to Login</router-link>
</div>
</CardFooter>
</Card>
<div class="min-h-screen flex flex-col bg-gray-50">
<header class="p-6">
<h1 class="text-xl font-bold text-gray-900">LibreDesk</h1>
</header>
<main class="flex-1 flex items-center justify-center p-4">
<div class="w-full max-w-[400px]">
<Card class="bg-white border border-gray-200 shadow-lg">
<CardContent class="p-8 space-y-6">
<div class="space-y-2 text-center">
<CardTitle class="text-2xl font-bold text-gray-900">Reset Password</CardTitle>
<p class="text-gray-600">Enter your email to receive a password reset link.</p>
</div>
<form @submit.prevent="requestResetAction" class="space-y-4">
<div class="space-y-2">
<Label for="email" class="text-sm font-medium text-gray-700">Email</Label>
<Input
id="email"
type="email"
placeholder="Enter your email address"
v-model.trim="resetForm.email"
:class="{ 'border-red-500': emailHasError }"
class="w-full bg-white border-gray-300 text-gray-900 placeholder:text-gray-400"
/>
</div>
<Button
class="w-full bg-primary hover:bg-slate-500 text-white"
:disabled="isLoading"
type="submit"
>
<span v-if="isLoading" class="flex items-center justify-center">
<svg
class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Sending...
</span>
<span v-else>Send Reset Link</span>
</Button>
</form>
</div>
</div>
<Error
v-if="errorMessage"
:errorMessage="errorMessage"
:border="true"
class="w-full bg-red-50 text-red-600 border-red-200 p-3 rounded-md text-sm"
/>
<div class="text-center">
<router-link to="/" class="text-sm text-blue-600 hover:text-blue-500"
>Back to Login</router-link
>
</div>
</CardContent>
</Card>
</div>
</main>
<footer class="p-6 text-center">
<div class="text-sm text-gray-500 space-x-4">
<a href="#" class="hover:text-gray-700">Privacy Policy</a>
<span></span>
<a href="#" class="hover:text-gray-700">Terms of Service</a>
<span></span>
<a href="#" class="hover:text-gray-700">Legal Notice</a>
</div>
</footer>
</div>
</template>
<script setup>
@@ -42,58 +97,57 @@ import { useToast } from '@/components/ui/toast/use-toast'
import { useTemporaryClass } from '@/composables/useTemporaryClass'
import { Button } from '@/components/ui/button'
import { Error } from '@/components/ui/error'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Card, CardContent, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import Spinner from '@/components/ui/spinner/Spinner.vue'
const errorMessage = ref('')
const isLoading = ref(false)
const router = useRouter()
const { toast } = useToast()
const resetForm = ref({
email: ''
email: ''
})
const validateForm = () => {
if (!validateEmail(resetForm.value.email)) {
errorMessage.value = 'Invalid email address.'
useTemporaryClass('reset-password-container', 'animate-shake')
return false
}
return true
if (!validateEmail(resetForm.value.email)) {
errorMessage.value = 'Invalid email address.'
useTemporaryClass('reset-password-container', 'animate-shake')
return false
}
return true
}
const requestResetAction = async () => {
if (!validateForm()) return
if (!validateForm()) return
errorMessage.value = ''
isLoading.value = true
errorMessage.value = ''
isLoading.value = true
try {
await api.resetPassword({
email: resetForm.value.email
})
toast({
title: 'Reset link sent',
description: 'Please check your email for the reset link.',
variant: 'success'
})
router.push({ name: 'login' })
} catch (err) {
toast({
title: 'Error',
description: err.response.data.message,
variant: 'destructive'
})
errorMessage.value = handleHTTPError(err).message
useTemporaryClass('reset-password-container', 'animate-shake')
} finally {
isLoading.value = false
}
try {
await api.resetPassword({
email: resetForm.value.email
})
toast({
title: 'Reset link sent',
description: 'Please check your email for the reset link.',
variant: 'success'
})
router.push({ name: 'login' })
} catch (err) {
toast({
title: 'Error',
description: err.response.data.message,
variant: 'destructive'
})
errorMessage.value = handleHTTPError(err).message
useTemporaryClass('reset-password-container', 'animate-shake')
} finally {
isLoading.value = false
}
}
const emailHasError = computed(() => {
return !validateEmail(resetForm.value.email) && resetForm.value.email !== ''
return !validateEmail(resetForm.value.email) && resetForm.value.email !== ''
})
</script>
</script>

View File

@@ -0,0 +1,113 @@
<template>
<div class="overflow-y-scroll h-full">
<SearchHeader v-model="searchQuery" @search="handleSearch" />
<div v-if="loading" class="flex justify-center items-center h-64">
<div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary"></div>
</div>
<div v-else-if="error" class="mt-8 text-center">
<p class="text-lg text-destructive">{{ error }}</p>
<button
@click="handleSearch"
class="mt-4 px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors"
>
Try Again
</button>
</div>
<div v-else>
<p
v-if="searchPerformed && totalResults === 0"
class="mt-8 text-center text-muted-foreground"
>
No results found for "{{ searchQuery }}". Try a different search term.
</p>
<SearchResults v-else-if="searchPerformed" :results="results" />
<p
v-else-if="searchQuery.length > 0 && searchQuery.length < MIN_SEARCH_LENGTH"
class="mt-8 text-center text-muted-foreground"
>
Please enter at least {{ MIN_SEARCH_LENGTH }} characters to search.
</p>
<!-- New component for when search is not performed -->
<div v-else class="mt-16 text-center">
<h2 class="text-2xl font-semibold text-primary mb-4">Search conversations</h2>
<p class="text-lg text-muted-foreground">
Search by reference number, messages, or any keywords related to your conversations.
</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onBeforeUnmount } from 'vue'
import SearchHeader from '@/components/search/SearchHeader.vue'
import SearchResults from '@/components/search/SearchResults.vue'
import api from '@/api'
const MIN_SEARCH_LENGTH = 2
const DEBOUNCE_DELAY = 300
const searchQuery = ref('')
const results = ref({ conversations: [], messages: [] })
const loading = ref(false)
const error = ref(null)
const searchPerformed = ref(false)
let debounceTimer = null
const totalResults = computed(() => {
return results.value.conversations.length + results.value.messages.length
})
const handleSearch = async () => {
if (searchQuery.value.length < MIN_SEARCH_LENGTH) {
results.value = { conversations: [], messages: [] }
searchPerformed.value = false
return
}
loading.value = true
error.value = null
searchPerformed.value = true
try {
const [convResults, messagesResults] = await Promise.all([
api.searchConversations({ query: searchQuery.value }),
api.searchMessages({ query: searchQuery.value })
])
results.value = {
conversations: convResults.data.data,
messages: messagesResults.data.data
}
} catch (err) {
console.error(err)
error.value = 'An error occurred while searching. Please try again.'
} finally {
loading.value = false
}
}
const debouncedSearch = () => {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(handleSearch, DEBOUNCE_DELAY)
}
watch(searchQuery, (newValue) => {
if (newValue.length >= MIN_SEARCH_LENGTH) {
debouncedSearch()
} else {
clearTimeout(debounceTimer)
results.value = { conversations: [], messages: [] }
searchPerformed.value = false
}
})
onBeforeUnmount(() => {
clearTimeout(debounceTimer)
})
</script>

View File

@@ -1,38 +1,98 @@
<template>
<Spinner v-if="isLoading"></Spinner>
<div class="relative h-screen" id="set-password-container" :class="{ 'soft-fade': isLoading }">
<div class="absolute left-1/2 top-20 transform -translate-x-1/2 w-96 h-1/2">
<form @submit.prevent="setPasswordAction">
<Card>
<CardHeader class="space-y-1">
<CardTitle class="text-2xl text-center">Set New Password</CardTitle>
<p class="text-sm text-muted-foreground text-center">
Please enter your new password twice to confirm.
</p>
</CardHeader>
<CardContent class="grid gap-4">
<div class="grid gap-2">
<Label for="password">New Password</Label>
<Input id="password" type="password" placeholder="Enter new password"
v-model="passwordForm.password" :class="{ 'border-red-500': passwordHasError }" />
</div>
<div class="grid gap-2">
<Label for="confirmPassword">Confirm Password</Label>
<Input id="confirmPassword" type="password" placeholder="Confirm new password"
v-model="passwordForm.confirmPassword"
:class="{ 'border-red-500': confirmPasswordHasError }" />
</div>
</CardContent>
<CardFooter class="flex flex-col gap-5">
<Button class="w-full" @click.prevent="setPasswordAction" :disabled="isLoading" type="submit">
Set New Password
</Button>
<Error :errorMessage="errorMessage" :border="true"></Error>
</CardFooter>
</Card>
<div class="min-h-screen flex flex-col bg-gray-50">
<header class="p-6">
<h1 class="text-xl font-bold text-gray-900">LibreDesk</h1>
</header>
<main class="flex-1 flex items-center justify-center p-4">
<div class="w-full max-w-[400px]">
<Card class="bg-white border border-gray-200 shadow-lg">
<CardContent class="p-8 space-y-6">
<div class="space-y-2 text-center">
<CardTitle class="text-2xl font-bold text-gray-900">Set New Password</CardTitle>
<p class="text-gray-600">Please enter your new password twice to confirm.</p>
</div>
<form @submit.prevent="setPasswordAction" class="space-y-4">
<div class="space-y-2">
<Label for="password" class="text-sm font-medium text-gray-700">New Password</Label>
<Input
id="password"
type="password"
placeholder="Enter new password"
v-model="passwordForm.password"
:class="{ 'border-red-500': passwordHasError }"
class="w-full bg-white border-gray-300 text-gray-900 placeholder:text-gray-400"
/>
</div>
<div class="space-y-2">
<Label for="confirmPassword" class="text-sm font-medium text-gray-700"
>Confirm Password</Label
>
<Input
id="confirmPassword"
type="password"
placeholder="Confirm new password"
v-model="passwordForm.confirmPassword"
:class="{ 'border-red-500': confirmPasswordHasError }"
class="w-full bg-white border-gray-300 text-gray-900 placeholder:text-gray-400"
/>
</div>
<Button
class="w-full bg-primary hover:bg-slate-500 text-white"
:disabled="isLoading"
type="submit"
>
<span v-if="isLoading" class="flex items-center justify-center">
<svg
class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Setting password...
</span>
<span v-else>Set New Password</span>
</Button>
</form>
</div>
</div>
<Error
v-if="errorMessage"
:errorMessage="errorMessage"
:border="true"
class="w-full bg-red-50 text-red-600 border-red-200 p-3 rounded-md text-sm"
/>
</CardContent>
</Card>
</div>
</main>
<footer class="p-6 text-center">
<div class="text-sm text-gray-500 space-x-4">
<a href="#" class="hover:text-gray-700">Privacy Policy</a>
<span></span>
<a href="#" class="hover:text-gray-700">Terms of Service</a>
<span></span>
<a href="#" class="hover:text-gray-700">Legal Notice</a>
</div>
</footer>
</div>
</template>
<script setup>
@@ -44,10 +104,9 @@ import { useToast } from '@/components/ui/toast/use-toast'
import { useTemporaryClass } from '@/composables/useTemporaryClass'
import { Button } from '@/components/ui/button'
import { Error } from '@/components/ui/error'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Card, CardContent, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import Spinner from '@/components/ui/spinner/Spinner.vue'
const errorMessage = ref('')
const isLoading = ref(false)
@@ -55,77 +114,79 @@ const router = useRouter()
const route = useRoute()
const { toast } = useToast()
const passwordForm = ref({
password: '',
confirmPassword: '',
token: ''
password: '',
confirmPassword: '',
token: ''
})
onMounted(() => {
passwordForm.value.token = route.query.token
if (!passwordForm.value.token) {
router.push({ name: 'login' })
toast({
title: 'Error',
description: 'Invalid reset link. Please request a new password reset link.',
variant: 'destructive'
})
}
passwordForm.value.token = route.query.token
if (!passwordForm.value.token) {
router.push({ name: 'login' })
toast({
title: 'Error',
description: 'Invalid reset link. Please request a new password reset link.',
variant: 'destructive'
})
}
})
const validateForm = () => {
if (!passwordForm.value.password || passwordForm.value.password.length < 8) {
errorMessage.value = 'Password must be at least 8 characters long.'
useTemporaryClass('set-password-container', 'animate-shake')
return false
}
if (!passwordForm.value.password || passwordForm.value.password.length < 8) {
errorMessage.value = 'Password must be at least 8 characters long.'
useTemporaryClass('set-password-container', 'animate-shake')
return false
}
if (passwordForm.value.password !== passwordForm.value.confirmPassword) {
errorMessage.value = 'Passwords do not match.'
useTemporaryClass('set-password-container', 'animate-shake')
return false
}
if (passwordForm.value.password !== passwordForm.value.confirmPassword) {
errorMessage.value = 'Passwords do not match.'
useTemporaryClass('set-password-container', 'animate-shake')
return false
}
return true
return true
}
const setPasswordAction = async () => {
if (!validateForm()) return
if (!validateForm()) return
errorMessage.value = ''
isLoading.value = true
errorMessage.value = ''
isLoading.value = true
try {
await api.setPassword({
token: passwordForm.value.token,
password: passwordForm.value.password
})
try {
await api.setPassword({
token: passwordForm.value.token,
password: passwordForm.value.password
})
toast({
title: 'Password set successfully',
description: 'You can now login with your new password.',
variant: 'success'
})
toast({
title: 'Password set successfully',
description: 'You can now login with your new password.',
variant: 'success'
})
router.push({ name: 'login' })
} catch (err) {
toast({
title: 'Error',
description: err.response.data.message,
variant: 'destructive'
})
errorMessage.value = handleHTTPError(err).message
useTemporaryClass('set-password-container', 'animate-shake')
} finally {
isLoading.value = false
}
router.push({ name: 'login' })
} catch (err) {
toast({
title: 'Error',
description: err.response.data.message,
variant: 'destructive'
})
errorMessage.value = handleHTTPError(err).message
useTemporaryClass('set-password-container', 'animate-shake')
} finally {
isLoading.value = false
}
}
const passwordHasError = computed(() => {
return passwordForm.value.password !== '' && passwordForm.value.password.length < 8
return passwordForm.value.password !== '' && passwordForm.value.password.length < 8
})
const confirmPasswordHasError = computed(() => {
return passwordForm.value.confirmPassword !== '' &&
passwordForm.value.password !== passwordForm.value.confirmPassword
return (
passwordForm.value.confirmPassword !== '' &&
passwordForm.value.password !== passwordForm.value.confirmPassword
)
})
</script>
</script>

View File

@@ -1,53 +1,132 @@
<template>
<div class="relative h-screen" id="login-container">
<div class="absolute left-1/2 top-20 transform -translate-x-1/2 w-96 h-1/2">
<Card>
<CardHeader class="space-y-1">
<CardTitle class="text-2xl text-center">LibreDesk</CardTitle>
</CardHeader>
<CardContent class="grid gap-4">
<div v-for="oidcProvider in enabledOIDCProviders" :key="oidcProvider.id" class="grid grid-cols-1 gap-6">
<Button variant="outline" type="button" @click="redirectToOIDC(oidcProvider)">
<img :src="oidcProvider.logo_url" width="15" class="mr-2" />
{{ oidcProvider.name }}
</Button>
</div>
<div class="relative" v-if="enabledOIDCProviders.length">
<div class="absolute inset-0 flex items-center">
<span class="w-full border-t"></span>
<div class="min-h-screen flex flex-col bg-gray-50">
<header class="p-6">
<h1 class="text-xl font-bold text-gray-900">LibreDesk</h1>
</header>
<main class="flex-1 flex items-center justify-center p-4">
<div class="w-full max-w-[400px]">
<Card class="bg-white border border-gray-200 shadow-lg">
<CardContent class="p-8 space-y-6">
<div class="space-y-2 text-center">
<CardTitle class="text-2xl font-bold text-gray-900">Sign in</CardTitle>
<p class="text-gray-600">Sign in to your account</p>
</div>
<div class="relative flex justify-center text-xs uppercase">
<span class="bg-background px-2 text-muted-foreground">Or continue with</span>
</div>
</div>
<form @submit.prevent="loginAction" class="space-y-4">
<div class="grid gap-2">
<Label for="email">Email</Label>
<Input id="email" type="text" placeholder="Enter your email address" v-model.trim="loginForm.email"
:class="{ 'border-red-500': emailHasError }" />
</div>
<div class="grid gap-2">
<Label for="password">Password</Label>
<Input id="password" type="password" placeholder="Password" v-model="loginForm.password"
:class="{ 'border-red-500': passwordHasError }" />
</div>
<div>
<Button class="w-full" :disabled="isLoading" :isLoading="isLoading" type="submit">
Login
<div v-if="enabledOIDCProviders.length" class="space-y-3">
<Button
v-for="oidcProvider in enabledOIDCProviders"
:key="oidcProvider.id"
variant="outline"
type="button"
@click="redirectToOIDC(oidcProvider)"
class="w-full bg-white hover:bg-gray-50 text-gray-700 border-gray-300"
>
<img :src="oidcProvider.logo_url" width="20" class="mr-2" alt="" />
{{ oidcProvider.name }}
</Button>
<div class="relative">
<div class="absolute inset-0 flex items-center">
<span class="w-full border-t border-gray-200"></span>
</div>
<div class="relative flex justify-center text-xs uppercase">
<span class="px-2 text-gray-500 bg-white">Or continue with</span>
</div>
</div>
</div>
</form>
</CardContent>
<CardFooter class="flex flex-col gap-5">
<Error :errorMessage="errorMessage" :border="true"></Error>
<div>
<router-link to="/reset-password" class="text-xs text-primary">
Forgot password?
</router-link>
</div>
</CardFooter>
</Card>
</div>
<form @submit.prevent="loginAction" class="space-y-4">
<div class="space-y-2">
<Label for="email" class="text-sm font-medium text-gray-700">Email</Label>
<Input
id="email"
type="email"
placeholder="Enter your email"
v-model.trim="loginForm.email"
:class="{ 'border-red-500': emailHasError }"
class="w-full bg-white border-gray-300 text-gray-900 placeholder:text-gray-400"
/>
</div>
<div class="space-y-2">
<Label for="password" class="text-sm font-medium text-gray-700">Password</Label>
<Input
id="password"
type="password"
placeholder="Enter your password"
v-model="loginForm.password"
:class="{ 'border-red-500': passwordHasError }"
class="w-full bg-white border-gray-300 text-gray-900 placeholder:text-gray-400"
/>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center space-x-2">
<input
type="checkbox"
id="remember"
class="w-4 h-4 rounded bg-white border-gray-300 text-blue-600"
/>
<Label for="remember" class="text-sm text-gray-600">Remember me</Label>
</div>
<router-link to="/reset-password" class="text-sm text-blue-600 hover:text-blue-500">
Forgot password?
</router-link>
</div>
<Button
class="w-full bg-primary hover:bg-slate-500 text-white"
:disabled="isLoading"
type="submit"
>
<span v-if="isLoading" class="flex items-center justify-center">
<svg
class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Logging in...
</span>
<span v-else>Sign in</span>
</Button>
</form>
<Error
v-if="errorMessage"
:errorMessage="errorMessage"
:border="true"
class="w-full bg-red-50 text-red-600 border-red-200 p-3 rounded-md text-sm"
/>
</CardContent>
</Card>
</div>
</main>
<footer class="p-6 text-center">
<div class="text-sm text-gray-500 space-x-4">
<a href="#" class="hover:text-gray-700">Privacy Policy</a>
<span></span>
<a href="#" class="hover:text-gray-700">Terms of Service</a>
<span></span>
<a href="#" class="hover:text-gray-700">Legal Notice</a>
</div>
</footer>
</div>
</template>
@@ -61,7 +140,7 @@ import { useToast } from '@/components/ui/toast/use-toast'
import { useTemporaryClass } from '@/composables/useTemporaryClass'
import { Button } from '@/components/ui/button'
import { Error } from '@/components/ui/error'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Card, CardContent, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -122,7 +201,7 @@ const loginAction = () => {
password: loginForm.value.password
})
.then(() => {
router.push({ name: 'inboxes' })
router.push({ name: 'inboxes' })
})
.catch((error) => {
errorMessage.value = handleHTTPError(error).message
@@ -138,8 +217,11 @@ const enabledOIDCProviders = computed(() => {
})
const emailHasError = computed(() => {
const email = loginForm.value.email;
return email !== 'System' && !validateEmail(email) && email !== '';
const email = loginForm.value.email
return email !== 'System' && !validateEmail(email) && email !== ''
})
const passwordHasError = computed(() => !loginForm.value.password && loginForm.value.password !== '')
</script>
const passwordHasError = computed(
() => !loginForm.value.password && loginForm.value.password !== ''
)
</script>

View File

@@ -1,145 +1,176 @@
import { useConversationStore } from './stores/conversation';
import { CONVERSATION_WS_ACTIONS } from './constants/conversation';
import { useConversationStore } from './stores/conversation'
import { WS_EVENT } from './constants/websocket'
let socket;
let reconnectInterval = 1000;
let maxReconnectInterval = 30000;
let reconnectTimeout;
let isReconnecting = false;
let manualClose = false;
let convStore;
function initializeWebSocket () {
socket = new WebSocket('/ws');
socket.addEventListener('open', handleOpen)
socket.addEventListener('message', handleMessage)
socket.addEventListener('error', handleError)
socket.addEventListener('close', handleClose)
}
function handleOpen () {
console.log('WebSocket connection established')
reconnectInterval = 1000;
if (reconnectTimeout) {
clearTimeout(reconnectTimeout)
reconnectTimeout = null;
export class WebSocketClient {
constructor() {
this.socket = null
this.reconnectInterval = 1000
this.maxReconnectInterval = 30000
this.reconnectAttempts = 0
this.maxReconnectAttempts = 50
this.isReconnecting = false
this.manualClose = false
this.messageQueue = []
this.pingInterval = null
this.lastPong = Date.now()
this.convStore = useConversationStore()
}
}
function handleMessage (event) {
try {
if (event.data) {
const data = JSON.parse(event.data)
switch (data.type) {
case 'new_message':
convStore.updateConversationLastMessage(data.data)
convStore.updateConversationMessageList(data.data)
break;
case 'message_prop_update':
convStore.updateMessageProp(data.data)
break;
case 'new_conversation':
convStore.addNewConversation(data.data)
break;
case 'conversation_prop_update':
convStore.updateConversationProp(data.data)
break;
default:
console.warn(`Unknown websocket event type: ${data.type}`)
init () {
this.connect()
this.setupNetworkListeners()
}
connect () {
try {
this.socket = new WebSocket('/ws')
this.socket.addEventListener('open', this.handleOpen.bind(this))
this.socket.addEventListener('message', this.handleMessage.bind(this))
this.socket.addEventListener('error', this.handleError.bind(this))
this.socket.addEventListener('close', this.handleClose.bind(this))
} catch (error) {
console.error('WebSocket connection error:', error)
this.reconnect()
}
}
handleOpen () {
console.log('WebSocket connected')
this.reconnectInterval = 1000
this.reconnectAttempts = 0
this.isReconnecting = false
this.lastPong = Date.now()
this.setupPing()
this.flushMessageQueue()
}
handleMessage (event) {
try {
if (!event.data) return
if (event.data === 'pong') {
this.lastPong = Date.now()
return
}
const data = JSON.parse(event.data)
const handlers = {
[WS_EVENT.NEW_MESSAGE]: () => {
this.convStore.updateConversationList(data.data)
this.convStore.updateConversationMessage(data.data)
},
[WS_EVENT.MESSAGE_PROP_UPDATE]: () => this.convStore.updateMessageProp(data.data),
[WS_EVENT.CONVERSATION_PROP_UPDATE]: () => this.convStore.updateConversationProp(data.data)
}
const handler = handlers[data.type]
if (handler) {
handler()
} else {
console.warn(`Unknown websocket event: ${data.type}`)
}
} catch (error) {
console.error('Message handling error:', error)
}
}
handleError (event) {
console.error('WebSocket error:', event)
this.reconnect()
}
handleClose () {
this.clearPing()
if (!this.manualClose) {
this.reconnect()
}
}
reconnect () {
if (this.isReconnecting || this.reconnectAttempts >= this.maxReconnectAttempts) return
this.isReconnecting = true
this.reconnectAttempts++
setTimeout(() => {
this.connect()
this.reconnectInterval = Math.min(this.reconnectInterval * 1.5, this.maxReconnectInterval)
}, this.reconnectInterval)
}
setupNetworkListeners () {
window.addEventListener('online', () => {
if (this.socket?.readyState !== WebSocket.OPEN) {
this.reconnectInterval = 1000
this.reconnect()
}
})
window.addEventListener('focus', () => {
if (this.socket?.readyState !== WebSocket.OPEN) {
this.reconnect()
}
})
}
setupPing () {
this.clearPing()
this.pingInterval = setInterval(() => {
if (this.socket?.readyState === WebSocket.OPEN) {
try {
this.socket.send('ping')
if (Date.now() - this.lastPong > 10000) {
console.warn('No pong received in 10 seconds, closing connection')
this.socket.close()
}
} catch (e) {
this.reconnect()
}
}
}, 5000)
}
clearPing () {
if (this.pingInterval) {
clearInterval(this.pingInterval)
this.pingInterval = null
}
}
send (message) {
if (this.socket?.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(message))
} else {
this.messageQueue.push(message)
}
}
flushMessageQueue () {
console.log('flushing message queue')
while (this.messageQueue.length > 0) {
const message = this.messageQueue.shift()
this.send(message)
}
}
close () {
this.manualClose = true
this.clearPing()
if (this.socket) {
this.socket.close()
}
} catch (error) {
console.error('Error handling WebSocket message:', error)
}
}
function handleError (event) {
console.error('WebSocket error observed:', event)
}
function handleClose () {
if (!manualClose) {
reconnect()
}
}
function reconnect () {
if (isReconnecting) return;
isReconnecting = true;
reconnectTimeout = setTimeout(() => {
initializeWebSocket()
reconnectInterval = Math.min(reconnectInterval * 2, maxReconnectInterval)
isReconnecting = false;
}, reconnectInterval)
}
function setupNetworkListeners () {
window.addEventListener('online', () => {
if (!isReconnecting && socket.readyState !== WebSocket.OPEN) {
reconnectInterval = 1000;
reconnect()
}
})
}
let wsClient
export function initWS () {
convStore = useConversationStore()
initializeWebSocket()
setupNetworkListeners()
}
function waitForWebSocketOpen (callback) {
if (socket) {
if (socket.readyState === WebSocket.OPEN) {
callback()
} else {
socket.addEventListener('open', function handler () {
socket.removeEventListener('open', handler)
callback()
})
}
if (!wsClient) {
wsClient = new WebSocketClient()
wsClient.init()
}
return wsClient
}
export function sendMessage (message) {
waitForWebSocketOpen(() => {
socket.send(JSON.stringify(message))
})
}
export function subscribeConversationsList (type, teamID) {
const message = {
action: CONVERSATION_WS_ACTIONS.SUB_LIST,
type: type,
team_id: parseInt(teamID, 10),
}
waitForWebSocketOpen(() => {
socket.send(JSON.stringify(message))
})
}
export function setCurrentConversation (uuid) {
const message = {
action: CONVERSATION_WS_ACTIONS.SET_CURRENT,
uuid: uuid,
}
waitForWebSocketOpen(() => {
socket.send(JSON.stringify(message))
})
}
export function unsetCurrentConversation () {
const message = {
action: CONVERSATION_WS_ACTIONS.UNSET_CURRENT
}
waitForWebSocketOpen(() => {
socket.send(JSON.stringify(message))
})
}
export function closeWebSocket () {
manualClose = true;
if (socket) {
socket.close()
}
}
export const sendMessage = message => wsClient?.send(message)
export const closeWebSocket = () => wsClient?.close()

View File

@@ -25,6 +25,8 @@ module.exports = {
extend: {
fontFamily: {
jakarta: ['Plus Jakarta Sans', 'Helvetica Neue', 'sans-serif'],
inter: ['Inter', 'Helvetica Neue', 'sans-serif'],
poppins: ['Poppins', 'Helvetica Neue', 'sans-serif'],
},
colors: {
border: "hsl(var(--border))",
@@ -93,14 +95,41 @@ module.exports = {
opacity: '1',
transform: 'translateY(0)'
},
}
},
'bounce-in': {
'0%': { transform: 'scale(0)' },
'50%': { transform: 'scale(1.2)' },
'100%': { transform: 'scale(1)' },
},
'fade-in': {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
'fade-out': {
'0%': { opacity: '1' },
'100%': { opacity: '0' },
},
'slide-in': {
'0%': { transform: 'translateY(20px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' }
},
'slide-out': {
'0%': { transform: 'translateY(0)', opacity: '1' },
'100%': { transform: 'translateY(20px)', opacity: '0' }
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
'collapsible-down': 'collapsible-down 0.2s ease-in-out',
'collapsible-up': 'collapsible-up 0.2s ease-in-out',
'fade-in-down': 'fade-in-down 0.3s ease-out'
'fade-in-down': 'fade-in-down 0.3s ease-out',
'bounce-in': 'bounce-in 0.3s',
'bounce-out': 'bounce-in 0.3s reverse',
'fade-in': 'fade-in 0.3s ease-out',
'fade-out': 'fade-out 0.3s ease-in',
'slide-in': 'slide-in 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
'slide-out': 'slide-out 0.3s cubic-bezier(0.4, 0, 0.2, 1)'
},
},
},

View File

@@ -111,7 +111,7 @@ func (e *Engine) ReloadRules() {
// Run starts the Engine with a worker pool to evaluate rules based on events.
func (e *Engine) Run(ctx context.Context, workerCount int) {
// Start the worker pool
// Spawn worker pool.
for i := 0; i < workerCount; i++ {
e.wg.Add(1)
go e.worker(ctx)
@@ -304,28 +304,39 @@ func (e *Engine) EvaluateConversationUpdateRules(conversationUUID string, eventT
// handleNewConversation handles new conversation events.
func (e *Engine) handleNewConversation(conversationUUID string) {
e.lo.Debug("handling new conversation", "uuid", conversationUUID)
conversation, err := e.conversationStore.GetConversation(0, conversationUUID)
if err != nil {
e.lo.Error("error fetching conversation for new event", "uuid", conversationUUID, "error", err)
return
}
rules := e.filterRulesByType(models.RuleTypeNewConversation, "")
if len(rules) == 0 {
e.lo.Warn("no rules to evaluate for new conversation", "uuid", conversationUUID)
return
}
e.evalConversationRules(rules, conversation)
}
// handleUpdateConversation handles update conversation events with specific eventType.
func (e *Engine) handleUpdateConversation(conversationUUID, eventType string) {
e.lo.Debug("handling update conversation", "uuid", conversationUUID, "event_type", eventType)
conversation, err := e.conversationStore.GetConversation(0, conversationUUID)
if err != nil {
e.lo.Error("error fetching conversation for update event", "uuid", conversationUUID, "error", err)
return
}
rules := e.filterRulesByType(models.RuleTypeConversationUpdate, eventType)
if len(rules) == 0 {
e.lo.Warn("no rules to evaluate for conversation update", "uuid", conversationUUID, "event_type", eventType)
return
}
e.evalConversationRules(rules, conversation)
}
// handleTimeTrigger handles time trigger events.
func (e *Engine) handleTimeTrigger() {
e.lo.Debug("handling time trigger")
thirtyDaysAgo := time.Now().Add(-30 * 24 * time.Hour)
conversations, err := e.conversationStore.GetConversationsCreatedAfter(thirtyDaysAgo)
if err != nil {
@@ -333,6 +344,10 @@ func (e *Engine) handleTimeTrigger() {
return
}
rules := e.filterRulesByType(models.RuleTypeTimeTrigger, "")
if len(rules) == 0 {
e.lo.Warn("no rules to evaluate for time trigger")
return
}
e.lo.Debug("fetched conversations for evaluating time triggers", "conversations_count", len(conversations), "rules_count", len(rules))
for _, conversation := range conversations {
e.evalConversationRules(rules, conversation)

View File

@@ -43,7 +43,7 @@ var (
)
const (
conversationsListMaxPageSize = 50
conversationsListMaxPageSize = 100
)
// Manager handles the operations related to conversations
@@ -169,7 +169,6 @@ type queries struct {
GetConversationsCreatedAfter *sqlx.Stmt `query:"get-conversations-created-after"`
GetUnassignedConversations *sqlx.Stmt `query:"get-unassigned-conversations"`
GetConversations string `query:"get-conversations"`
GetConversationsListUUIDs string `query:"get-conversations-list-uuids"`
GetConversationParticipants *sqlx.Stmt `query:"get-conversation-participants"`
UpdateConversationFirstReplyAt *sqlx.Stmt `query:"update-conversation-first-reply-at"`
UpdateConversationAssigneeLastSeen *sqlx.Stmt `query:"update-conversation-assignee-last-seen"`
@@ -244,7 +243,7 @@ func (c *Manager) UpdateConversationAssigneeLastSeen(uuid string) error {
}
// Broadcast the property update to all subscribers.
c.BroadcastConversationPropertyUpdate(uuid, "assignee_last_seen_at", time.Now().Format(time.RFC3339))
c.BroadcastConversationUpdate(uuid, "assignee_last_seen_at", time.Now().Format(time.RFC3339))
return nil
}
@@ -258,17 +257,6 @@ func (c *Manager) GetConversationParticipants(uuid string) ([]models.Conversatio
return conv, nil
}
// AddConversationParticipant adds a user as participant to a conversation.
func (c *Manager) AddConversationParticipant(userID int, conversationUUID string) error {
if _, err := c.q.InsertConverstionParticipant.Exec(userID, conversationUUID); err != nil {
if pgErr, ok := err.(*pq.Error); ok && pgErr.Code == "23505" {
return nil
}
return err
}
return nil
}
// GetUnassignedConversations retrieves unassigned conversations.
func (c *Manager) GetUnassignedConversations() ([]models.Conversation, error) {
var conv []models.Conversation
@@ -365,47 +353,6 @@ func (c *Manager) GetConversations(userID int, listType, order, orderBy, filters
return conversations, nil
}
// GetConversationsListUUIDs retrieves the UUIDs of conversations list, used to subscribe to conversations.
func (c *Manager) GetConversationsListUUIDs(userID, teamID, page, pageSize int, typ string) ([]string, error) {
var (
ids = make([]string, 0)
id = userID
)
if typ == models.TeamUnassignedConversations {
id = teamID
if teamID == 0 {
return ids, fmt.Errorf("team ID is required for team unassigned conversations")
}
exists, err := c.teamStore.UserBelongsToTeam(userID, teamID)
if err != nil {
return ids, fmt.Errorf("fetching team members: %w", err)
}
if !exists {
return ids, fmt.Errorf("user does not belong to team")
}
}
query, qArgs, err := c.makeConversationsListQuery(id, c.q.GetConversationsListUUIDs, typ, "", "", page, pageSize, "")
if err != nil {
c.lo.Error("error generating conversations query", "error", err)
return ids, err
}
tx, err := c.db.BeginTxx(context.Background(), nil)
defer tx.Rollback()
if err != nil {
c.lo.Error("error preparing get conversation ids query", "error", err)
return ids, err
}
if err := tx.Select(&ids, query, qArgs...); err != nil {
c.lo.Error("error fetching conversation uuids", "error", err)
return ids, err
}
return ids, nil
}
// UpdateConversationMeta updates the metadata of a conversation.
func (c *Manager) UpdateConversationMeta(conversationID int, conversationUUID string, meta map[string]string) error {
metaJSON, err := json.Marshal(meta)
@@ -439,7 +386,7 @@ func (c *Manager) UpdateConversationFirstReplyAt(conversationUUID string, conver
rows, _ := res.RowsAffected()
if rows > 0 {
c.BroadcastConversationPropertyUpdate(conversationUUID, "first_reply_at", at.Format(time.RFC3339))
c.BroadcastConversationUpdate(conversationUUID, "first_reply_at", at.Format(time.RFC3339))
}
return nil
}
@@ -497,7 +444,7 @@ func (c *Manager) UpdateAssignee(uuid string, assigneeID int, assigneeType strin
return fmt.Errorf("invalid assignee type: %s", assigneeType)
}
// Broadcast update to all subscribers.
c.BroadcastConversationPropertyUpdate(uuid, prop, assigneeID)
c.BroadcastConversationUpdate(uuid, prop, assigneeID)
return nil
}
@@ -518,6 +465,7 @@ func (c *Manager) UpdateConversationPriority(uuid string, priorityID int, priori
if err := c.RecordPriorityChange(priority, uuid, actor); err != nil {
return envelope.NewError(envelope.GeneralError, "Error recording priority change", nil)
}
c.BroadcastConversationUpdate(uuid, "priority", priority)
return nil
}
@@ -559,7 +507,7 @@ func (c *Manager) UpdateConversationStatus(uuid string, statusID int, status, sn
}
// Send WS update to all subscribers.
c.BroadcastConversationPropertyUpdate(uuid, "status", status)
c.BroadcastConversationUpdate(uuid, "status", status)
return nil
}
@@ -681,6 +629,7 @@ func (c *Manager) makeConversationsListQuery(userID int, baseQuery, listType, or
// Conversations assigned to a team but not to a specific user.
case models.TeamUnassignedConversations:
baseQuery = fmt.Sprintf(baseQuery, "AND conversations.assigned_team_id = $1 AND conversations.assigned_user_id IS NULL")
// UserID is the team ID in this case.
qArgs = append(qArgs, userID)
default:
return "", nil, fmt.Errorf("unknown conversation type: %s", listType)
@@ -689,13 +638,14 @@ func (c *Manager) makeConversationsListQuery(userID int, baseQuery, listType, or
// Build the paginated query.
query, qArgs, err := dbutil.BuildPaginatedQuery(baseQuery, qArgs, dbutil.PaginationOptions{
Order: order,
OrderBy: orderBy,
OrderBy: "",
Page: page,
PageSize: pageSize,
}, filtersJSON, dbutil.AllowedFields{
"conversations": ConversationsListAllowedFilterFields,
"conversation_statuses": ConversationStatusesFilterFields,
})
fmt.Println("Query: ", query)
if err != nil {
c.lo.Error("error preparing query", "error", err)
return "", nil, err
@@ -831,3 +781,14 @@ func (m *Manager) ApplyAction(action amodels.RuleAction, conversation models.Con
}
return nil
}
// addConversationParticipant adds a user as participant to a conversation.
func (c *Manager) addConversationParticipant(userID int, conversationUUID string) error {
if _, err := c.q.InsertConverstionParticipant.Exec(userID, conversationUUID); err != nil {
if !dbutil.IsUniqueViolationError(err) {
c.lo.Error("error adding conversation participant", "user_id", userID, "conversation_uuid", conversationUUID, "error", err)
return fmt.Errorf("adding conversation participant: %w", err)
}
}
return nil
}

View File

@@ -258,7 +258,7 @@ func (m *Manager) UpdateMessageStatus(uuid string, status string) error {
// Broadcast messge status update to all conversation subscribers.
conversationUUID, _ := m.getConversationUUIDFromMessageUUID(uuid)
m.BroadcastMessagePropUpdate(conversationUUID, uuid, "status" /*property*/, status)
m.BroadcastMessageUpdate(conversationUUID, uuid, "status" /*property*/, status)
return nil
}
@@ -315,8 +315,11 @@ func (m *Manager) InsertMessage(message *models.Message) error {
message.Meta = "{}"
}
// Generate text content.
message.TextContent = stringutil.HTML2Text(message.Content)
// Insert Message.
if err := m.q.InsertMessage.QueryRow(message.Type, message.Status, message.ConversationID, message.ConversationUUID, message.Content, message.SenderID, message.SenderType,
if err := m.q.InsertMessage.QueryRow(message.Type, message.Status, message.ConversationID, message.ConversationUUID, message.Content, message.TextContent, message.SenderID, message.SenderType,
message.Private, message.ContentType, message.SourceID, message.Meta).Scan(&message.ID, &message.UUID, &message.CreatedAt); err != nil {
m.lo.Error("error inserting message in db", "error", err)
return envelope.NewError(envelope.GeneralError, "Error sending message", nil)
@@ -328,22 +331,21 @@ func (m *Manager) InsertMessage(message *models.Message) error {
}
// Add this user as a participant.
if err := m.AddConversationParticipant(message.SenderID, message.ConversationUUID); err != nil {
return envelope.NewError(envelope.GeneralError, "Error sending message", nil)
if err := m.addConversationParticipant(message.SenderID, message.ConversationUUID); err != nil {
return envelope.NewError(envelope.GeneralError, err.Error(), nil)
}
// Update conversation last message details.
lastMessage := stringutil.HTML2Text(message.Content)
m.UpdateConversationLastMessage(message.ConversationID, message.ConversationUUID, lastMessage, message.CreatedAt)
m.UpdateConversationLastMessage(message.ConversationID, message.ConversationUUID, message.TextContent, message.CreatedAt)
// Broadcast new message to all conversation subscribers.
m.BroadcastNewConversationMessage(message.ConversationUUID, lastMessage, message.UUID, message.CreatedAt.Format(time.RFC3339), message.Type, message.Private)
m.BroadcastNewMessage(message.ConversationUUID, message.TextContent, message.UUID, message.CreatedAt.Format(time.RFC3339), message.Type, message.Private)
return nil
}
// RecordAssigneeUserChange records an activity for a user assignee change.
func (m *Manager) RecordAssigneeUserChange(conversationUUID string, assigneeID int, actor umodels.User) error {
// Self assign.
// Self assignment.
if assigneeID == actor.ID {
return m.InsertConversationActivity(ActivitySelfAssign, conversationUUID, actor.FullName(), actor)
}
@@ -574,7 +576,6 @@ func (m *Manager) uploadMessageAttachments(message *models.Message) error {
m.lo.Debug("uploading message attachment", "name", attachment.Name)
attachment.Name = stringutil.SanitizeFilename(attachment.Name)
reader := bytes.NewReader(attachment.Content)
_, err = m.mediaStore.UploadAndInsert(
attachment.Name,

View File

@@ -60,6 +60,7 @@ type Conversation struct {
ResolutionDueAt null.Time `db:"resolution_due_at" json:"resolution_due_at"`
SLAPolicyID null.Int `db:"sla_policy_id" json:"sla_policy_id"`
SlaPolicyName null.String `db:"sla_policy_name" json:"sla_policy_name"`
NextSLADeadlineAt null.Time `db:"next_sla_deadline_at" json:"next_sla_deadline_at"`
Total int `db:"total" json:"-"`
}
@@ -92,6 +93,7 @@ type Message struct {
Status string `db:"status" json:"status"`
ConversationID int `db:"conversation_id" json:"conversation_id"`
Content string `db:"content" json:"content"`
TextContent string `db:"text_content" json:"text_content"`
ContentType string `db:"content_type" json:"content_type"`
Private bool `db:"private" json:"private"`
SourceID null.String `db:"source_id" json:"-"`
@@ -121,6 +123,7 @@ func (m *Message) HideCSAT() {
}
if isCsat, _ := meta["is_csat"].(bool); isCsat {
m.Content = "Please rate your experience with us"
m.TextContent = m.Content
}
}

View File

@@ -27,6 +27,8 @@ SELECT
conversations.subject,
conversations.last_message,
conversations.last_message_at,
conversations.next_sla_deadline_at,
conversations.priority_id,
(
SELECT COUNT(*)
FROM conversation_messages m
@@ -41,14 +43,6 @@ FROM conversations
LEFT JOIN conversation_priorities ON conversations.priority_id = conversation_priorities.id
WHERE 1=1 %s
-- name: get-conversations-list-uuids
SELECT
conversations.uuid
FROM conversations
LEFT JOIN conversation_statuses ON conversations.status_id = conversation_statuses.id
LEFT JOIN conversation_priorities ON conversations.priority_id = conversation_priorities.id
WHERE 1=1 %s
-- name: get-conversation
SELECT
c.id,
@@ -466,6 +460,7 @@ INSERT INTO conversation_messages (
status,
conversation_id,
"content",
text_content,
sender_id,
sender_type,
private,
@@ -483,7 +478,8 @@ VALUES (
$8,
$9,
$10,
$11
$11,
$12
)
RETURNING id, uuid, created_at;

View File

@@ -2,13 +2,12 @@ package conversation
import (
"encoding/json"
"time"
wsmodels "github.com/abhinavxd/libredesk/internal/ws/models"
)
// BroadcastNewMessage broadcasts a new message to the conversation subscribers.
func (m *Manager) BroadcastNewConversationMessage(conversationUUID, content, messageUUID, lastMessageAt, typ string, private bool) {
// BroadcastNewMessage broadcasts a new message to all users.
func (m *Manager) BroadcastNewMessage(conversationUUID, content, messageUUID, lastMessageAt, typ string, private bool) {
message := wsmodels.Message{
Type: wsmodels.MessageTypeNewMessage,
Data: map[string]interface{}{
@@ -20,11 +19,11 @@ func (m *Manager) BroadcastNewConversationMessage(conversationUUID, content, mes
"type": typ,
},
}
m.broadcastToConversation(conversationUUID, message)
m.broadcastToUsers([]int{}, message)
}
// BroadcastMessagePropUpdate broadcasts a message property update to the conversation subscribers.
func (m *Manager) BroadcastMessagePropUpdate(conversationUUID, messageUUID, prop string, value any) {
// BroadcastMessageUpdate broadcasts a message update to all users.
func (m *Manager) BroadcastMessageUpdate(conversationUUID, messageUUID, prop string, value any) {
message := wsmodels.Message{
Type: wsmodels.MessageTypeMessagePropUpdate,
Data: map[string]interface{}{
@@ -33,29 +32,11 @@ func (m *Manager) BroadcastMessagePropUpdate(conversationUUID, messageUUID, prop
"value": value,
},
}
m.broadcastToConversation(conversationUUID, message)
m.broadcastToUsers([]int{}, message)
}
// BroadcastNewConversation broadcasts a new conversation to the user.
func (m *Manager) BroadcastNewConversation(userID int, conversationUUID, avatarURL, firstName, lastName, lastMessage, inboxName string, lastMessageAt time.Time, unreadMessageCount int) {
message := wsmodels.Message{
Type: wsmodels.MessageTypeNewConversation,
Data: map[string]interface{}{
"uuid": conversationUUID,
"avatar_url": avatarURL,
"first_name": firstName,
"last_name": lastName,
"last_message": lastMessage,
"last_message_at": lastMessageAt.Format(time.RFC3339),
"inbox_name": inboxName,
"unread_message_count": unreadMessageCount,
},
}
m.broadcastToUsers([]int{userID}, message)
}
// BroadcastConversationPropertyUpdate broadcasts a conversation property update to the conversation subscribers.
func (m *Manager) BroadcastConversationPropertyUpdate(conversationUUID, prop string, value any) {
// BroadcastConversationUpdate broadcasts a conversation update to all users.
func (m *Manager) BroadcastConversationUpdate(conversationUUID, prop string, value any) {
message := wsmodels.Message{
Type: wsmodels.MessageTypeConversationPropertyUpdate,
Data: map[string]interface{}{
@@ -64,21 +45,14 @@ func (m *Manager) BroadcastConversationPropertyUpdate(conversationUUID, prop str
"value": value,
},
}
m.broadcastToConversation(conversationUUID, message)
m.broadcastToUsers([]int{}, message)
}
// broadcastToConversation broadcasts a message to the conversation subscribers.
func (m *Manager) broadcastToConversation(conversationUUID string, message wsmodels.Message) {
userIDs := m.wsHub.GetConversationSubscribers(conversationUUID)
m.lo.Debug("broadcasting new message to conversation subscribers", "user_ids", userIDs, "conversation_uuid", conversationUUID, "message", message)
m.broadcastToUsers(userIDs, message)
}
// broadcastToUsers broadcasts a websocket message to the passed user IDs.
// broadcastToUsers broadcasts a message to a list of users, if the list is empty it broadcasts to all users.
func (m *Manager) broadcastToUsers(userIDs []int, message wsmodels.Message) {
messageBytes, err := json.Marshal(message)
if err != nil {
m.lo.Error("error marshlling message", "error", err)
m.lo.Error("error marshalling WS message", "error", err)
return
}
m.wsHub.BroadcastMessage(wsmodels.BroadcastMessage{

View File

@@ -0,0 +1,18 @@
package models
import "time"
type Conversation struct {
CreatedAt time.Time `db:"created_at" json:"created_at"`
UUID string `db:"uuid" json:"uuid"`
ReferenceNumber string `db:"reference_number" json:"reference_number"`
Subject string `db:"subject" json:"subject"`
}
type Message struct {
CreatedAt time.Time `db:"created_at" json:"created_at"`
TextContent string `db:"text_content" json:"text_content"`
ConversationCreatedAt time.Time `db:"conversation_created_at" json:"conversation_created_at"`
ConversationUUID string `db:"conversation_uuid" json:"conversation_uuid"`
ConversationReferenceNumber string `db:"conversation_reference_number" json:"conversation_reference_number"`
}

View File

@@ -0,0 +1,18 @@
-- name: search-conversations
SELECT
conversations.created_at,
conversations.uuid,
conversations.reference_number,
conversations.subject
FROM conversations
WHERE reference_number::text = $1;
-- name: search-messages
SELECT
c.created_at as "conversation_created_at",
c.reference_number as "conversation_reference_number",
c.uuid as "conversation_uuid",
m.text_content
FROM conversation_messages m
JOIN conversations c ON m.conversation_id = c.id
WHERE m.type != 'activity' and m.text_content ILIKE '%' || $1 || '%';

64
internal/search/search.go Normal file
View File

@@ -0,0 +1,64 @@
// Package search provides search functionality.
package search
import (
"embed"
"github.com/abhinavxd/libredesk/internal/dbutil"
"github.com/abhinavxd/libredesk/internal/envelope"
models "github.com/abhinavxd/libredesk/internal/search/models"
"github.com/jmoiron/sqlx"
"github.com/zerodha/logf"
)
var (
//go:embed queries.sql
efs embed.FS
)
// Manager is the search manager
type Manager struct {
q queries
lo *logf.Logger
}
// Opts contains the options for creating a new search manager
type Opts struct {
DB *sqlx.DB
Lo *logf.Logger
}
// queries contains all the prepared queries
type queries struct {
SearchConversations *sqlx.Stmt `query:"search-conversations"`
SearchMessages *sqlx.Stmt `query:"search-messages"`
}
// New creates a new search manager
func New(opts Opts) (*Manager, error) {
var q queries
if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
return nil, err
}
return &Manager{q: q, lo: opts.Lo}, nil
}
// Conversations searches conversations based on the query
func (s *Manager) Conversations(query string) ([]models.Conversation, error) {
var results = make([]models.Conversation, 0)
if err := s.q.SearchConversations.Select(&results, query); err != nil {
s.lo.Error("error searching conversations", "error", err)
return nil, envelope.NewError(envelope.GeneralError, "Error searching conversations", nil)
}
return results, nil
}
// Messages searches messages based on the query
func (s *Manager) Messages(query string) ([]models.Message, error) {
var results = make([]models.Message, 0)
if err := s.q.SearchMessages.Select(&results, query); err != nil {
s.lo.Error("error searching messages", "error", err)
return nil, envelope.NewError(envelope.GeneralError, "Error searching messages", nil)
}
return results, nil
}

View File

@@ -64,7 +64,7 @@ SELECT
c.assigned_team_id as conversation_assigned_team_id
FROM conversation_slas cs
LEFT JOIN conversations c ON cs.conversation_id = c.id
WHERE cs.breached_at is NULL AND cs.met_at is NULL;
WHERE cs.breached_at is NULL AND cs.met_at is NULL and c.sla_policy_id is NOT NULL;
-- name: update-breached-at
UPDATE conversation_slas

View File

@@ -311,6 +311,11 @@ func (m *Manager) evaluateSLA(cSLA models.ConversationSLA) error {
return nil
}
if _, err := m.q.UpdateDueAt.Exec(cSLA.ID, deadline); err != nil {
m.lo.Error("error updating SLA due_at", "error", err)
return fmt.Errorf("updating SLA due_at: %v", err)
}
if !compareTime.IsZero() {
if compareTime.After(deadline) {
return m.markSLABreached(cSLA.ID)
@@ -322,10 +327,6 @@ func (m *Manager) evaluateSLA(cSLA models.ConversationSLA) error {
return m.markSLABreached(cSLA.ID)
}
if _, err := m.q.UpdateDueAt.Exec(cSLA.ID, deadline); err != nil {
m.lo.Error("error updating SLA due_at", "error", err)
return fmt.Errorf("updating SLA due_at: %v", err)
}
return nil
}

View File

@@ -138,7 +138,7 @@ func (u *Manager) CreateAgent(user *models.User) error {
if err != nil {
u.lo.Error("error generating password", "error", err)
return envelope.NewError(envelope.GeneralError, "Error creating user", nil)
}
}
user.Email = null.NewString(strings.TrimSpace(strings.ToLower(user.Email.String)), user.Email.Valid)
if err := u.q.InsertAgent.QueryRow(user.Email, user.FirstName, user.LastName, password, user.AvatarURL, pq.Array(user.Roles)).Scan(&user.ID); err != nil {
u.lo.Error("error creating user", "error", err)
@@ -277,22 +277,6 @@ func (u *Manager) ResetPassword(token, password string) error {
return 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
hashedPassword, err := promptAndHashPassword(ctx)
if err != nil {
return err
}
// Update system user's password in the database.
if err := updateSystemUserPassword(db, hashedPassword); err != nil {
return fmt.Errorf("error updating system user password: %v", err)
}
fmt.Println("System user password updated successfully.")
return nil
}
// GetPermissions retrieves the permissions of a user by ID.
func (u *Manager) GetPermissions(id int) ([]string, error) {
var permissions []string
@@ -332,6 +316,22 @@ func (u *Manager) isStrongPassword(password string) bool {
return hasUppercase && hasNumber
}
// 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
hashedPassword, err := promptAndHashPassword(ctx)
if err != nil {
return err
}
// Update system user's password in the database.
if err := updateSystemUserPassword(db, hashedPassword); err != nil {
return fmt.Errorf("error updating system user password: %v", err)
}
fmt.Println("System user password updated successfully.")
return nil
}
// CreateSystemUser inserts a default system user into the users table with the prompted password.
func CreateSystemUser(ctx context.Context, db *sqlx.DB) error {
// Prompt for password and get hashed password

View File

@@ -11,11 +11,6 @@ import (
"github.com/fasthttp/websocket"
)
const (
maxConversationsPagesToSub = 10
maxConversationsPageSize = 50
)
// SafeBool is a thread-safe boolean.
type SafeBool struct {
flag bool
@@ -50,9 +45,6 @@ type Client struct {
// To prevent pushes to the channel.
Closed SafeBool
// Currently opened conversation UUID.
CurrentConversationUUID string
// Buffered channel of outbound ws messages.
Send chan models.WSMessage
}
@@ -102,120 +94,19 @@ func (c *Client) Listen() {
// processIncomingMessage processes incoming messages from the client.
func (c *Client) processIncomingMessage(data []byte) {
var req models.IncomingReq
if err := json.Unmarshal(data, &req); err != nil {
c.SendError("error unmarshalling request")
if string(data) == "ping" {
c.SendMessage([]byte("pong"), websocket.TextMessage)
return
}
switch req.Action {
case models.ActionConversationsListSub:
var sReq models.ConversationsListSubscribe
if err := json.Unmarshal(data, &sReq); err != nil {
c.SendError("error unmarshalling request: " + err.Error())
return
}
// First remove all user conversation subscriptions.
c.RemoveAllUserConversationSubscriptions(c.ID)
// Fetch conversations of this list and subscribe to them
for page := 1; page <= maxConversationsPagesToSub; page++ {
conversationUUIDs, err := c.Hub.conversationStore.GetConversationsListUUIDs(c.ID, sReq.TeamID, page, maxConversationsPageSize, sReq.Type)
if err != nil {
continue
}
c.SubscribeConversations(c.ID, conversationUUIDs)
}
case models.ActionSetCurrentConversation:
var subReq models.ConversationCurrentSet
if err := json.Unmarshal(data, &subReq); err != nil {
c.SendError("error unmarshalling request")
return
}
if c.CurrentConversationUUID != subReq.UUID {
c.UnsubscribeConversation(c.ID, c.CurrentConversationUUID)
c.CurrentConversationUUID = subReq.UUID
c.SubscribeConversations(c.ID, []string{subReq.UUID})
}
case models.ActionUnsetCurrentConversation:
c.UnsubscribeConversation(c.ID, c.CurrentConversationUUID)
c.CurrentConversationUUID = ""
default:
c.SendError("unknown action")
}
c.SendError("unknown incoming message type")
}
// close closes the client connection and removes all subscriptions.
// close closes the client connection.
func (c *Client) close() {
c.RemoveAllUserConversationSubscriptions(c.ID)
c.Closed.Set(true)
close(c.Send)
}
// SubscribeConversations subscribes the client to the specified conversations.
func (c *Client) SubscribeConversations(userID int, conversationUUIDs []string) {
c.Hub.subMutex.Lock()
defer c.Hub.subMutex.Unlock()
for _, conversationUUID := range conversationUUIDs {
// Initialize the slice if it doesn't exist
if c.Hub.conversationSubs[conversationUUID] == nil {
c.Hub.conversationSubs[conversationUUID] = []int{}
}
// Check if userID already exists
exists := false
for _, id := range c.Hub.conversationSubs[conversationUUID] {
if id == userID {
exists = true
break
}
}
// Add userID if it doesn't exist
if !exists {
c.Hub.conversationSubs[conversationUUID] = append(c.Hub.conversationSubs[conversationUUID], userID)
}
}
}
// RemoveAllUserConversationSubscriptions removes all conversation subscriptions for the user.
func (c *Client) RemoveAllUserConversationSubscriptions(userID int) {
c.Hub.subMutex.Lock()
defer c.Hub.subMutex.Unlock()
for conversationUUID, userIDs := range c.Hub.conversationSubs {
for i, id := range userIDs {
if id == userID {
c.Hub.conversationSubs[conversationUUID] = append(userIDs[:i], userIDs[i+1:]...)
break
}
}
if len(c.Hub.conversationSubs[conversationUUID]) == 0 {
delete(c.Hub.conversationSubs, conversationUUID)
}
}
}
// UnsubscribeConversation unsubscribes the user from the specified conversation.
func (c *Client) UnsubscribeConversation(userID int, conversationUUID string) {
c.Hub.subMutex.Lock()
defer c.Hub.subMutex.Unlock()
if userIDs, ok := c.Hub.conversationSubs[conversationUUID]; ok {
for i, id := range userIDs {
if id == userID {
c.Hub.conversationSubs[conversationUUID] = append(userIDs[:i], userIDs[i+1:]...)
break
}
}
// Remove the conversation from the map if no users are subscribed
if len(c.Hub.conversationSubs[conversationUUID]) == 0 {
delete(c.Hub.conversationSubs, conversationUUID)
}
}
}
// SendError sends an error message to client.
func (c *Client) SendError(msg string) {
out := models.Message{
@@ -224,7 +115,6 @@ func (c *Client) SendError(msg string) {
}
b, _ := json.Marshal(out)
// Try to send the error message over the Send channel
select {
case c.Send <- models.WSMessage{Data: b, MessageType: websocket.TextMessage}:
default:

View File

@@ -2,10 +2,6 @@ package models
// Action constants for WebSocket messages.
const (
ActionConversationsListSub = "conversations_list_sub"
ActionSetCurrentConversation = "conversation_set_current"
ActionUnsetCurrentConversation = "conversation_unset_current"
MessageTypeMessagePropUpdate = "message_prop_update"
MessageTypeConversationPropertyUpdate = "conversation_prop_update"
MessageTypeNewMessage = "new_message"
@@ -30,19 +26,3 @@ type BroadcastMessage struct {
Data []byte `json:"data"`
Users []int `json:"users"`
}
// IncomingReq represents an incoming WebSocket request.
type IncomingReq struct {
Action string `json:"action"`
}
// ConversationsListSubscribe represents a request to subscribe to conversations list
type ConversationsListSubscribe struct {
Type string `json:"type"`
TeamID int `json:"team_id"`
}
// ConversationCurrentSet represents a request to set current conversation
type ConversationCurrentSet struct {
UUID string `json:"uuid"`
}

View File

@@ -8,40 +8,21 @@ import (
"github.com/fasthttp/websocket"
)
// Hub maintains the set of registered clients and their subscriptions.
// Hub maintains the set of registered websockets clients.
type Hub struct {
// Client ID to WS Client map.
// Client ID to WS Client map, user can connect from multiple devices and each device will have a separate client.
clients map[int][]*Client
clientsMutex sync.Mutex
// Map of conversation UUID to client id list.
conversationSubs map[string][]int
subMutex sync.Mutex
// Store to fetch conversation UUIDs for subscriptions.
conversationStore ConversationStore
}
// ConversationStore defines the interface for retrieving conversation UUIDs.
type ConversationStore interface {
GetConversationsListUUIDs(userID, teamID, page, pageSize int, typ string) ([]string, error)
}
// NewHub creates a new Hub.
// NewHub creates a new websocket hub.
func NewHub() *Hub {
return &Hub{
clients: make(map[int][]*Client, 10000),
clientsMutex: sync.Mutex{},
conversationSubs: make(map[string][]int),
subMutex: sync.Mutex{},
clients: make(map[int][]*Client, 10000),
clientsMutex: sync.Mutex{},
}
}
// SetConversationStore sets the conversation store for the hub.
func (h *Hub) SetConversationStore(store ConversationStore) {
h.conversationStore = store
}
// AddClient adds a new client to the hub.
func (h *Hub) AddClient(client *Client) {
h.clientsMutex.Lock()
@@ -62,18 +43,27 @@ func (h *Hub) RemoveClient(client *Client) {
}
}
}
// BroadcastMessage broadcasts a message to the specified users.
// If no users are specified, the message is broadcast to all users.
func (h *Hub) BroadcastMessage(msg models.BroadcastMessage) {
h.clientsMutex.Lock()
defer h.clientsMutex.Unlock()
// Broadcast to all users if no users are specified.
if len(msg.Users) == 0 {
for _, clients := range h.clients {
for _, client := range clients {
client.SendMessage(msg.Data, websocket.TextMessage)
}
}
return
}
// Broadcast to specified users.
for _, userID := range msg.Users {
for _, client := range h.clients[userID] {
client.SendMessage(msg.Data, websocket.TextMessage)
}
}
}
func (h *Hub) GetConversationSubscribers(conversationUUID string) []int {
h.subMutex.Lock()
defer h.subMutex.Unlock()
return h.conversationSubs[conversationUUID]
}

View File

@@ -1,3 +1,5 @@
CREATE EXTENSION IF NOT EXISTS pg_trgm;
DROP TYPE IF EXISTS "channels" CASCADE; CREATE TYPE "channels" AS ENUM ('email');
DROP TYPE IF EXISTS "media_store" CASCADE; CREATE TYPE "media_store" AS ENUM ('s3', 'fs');
DROP TYPE IF EXISTS "message_type" CASCADE; CREATE TYPE "message_type" AS ENUM ('incoming','outgoing','activity');
@@ -140,11 +142,14 @@ CREATE TABLE conversation_messages (
conversation_id BIGSERIAL REFERENCES conversations(id) ON DELETE CASCADE ON UPDATE CASCADE,
content_type content_type NULL,
"content" TEXT NULL,
text_content TEXT NULL,
source_id TEXT NULL,
sender_id INT REFERENCES users(id) NULL,
sender_type message_sender_type NOT NULL,
meta JSONB DEFAULT '{}'::JSONB NULL
);
CREATE INDEX idx_conversation_messages_text_content ON conversation_messages
USING GIN (text_content gin_trgm_ops);
DROP TABLE IF EXISTS automation_rules CASCADE;
CREATE TABLE automation_rules (