mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-11-03 05:23:48 +00:00
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:
5
Makefile
5
Makefile
@@ -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..."
|
||||
|
||||
@@ -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"))
|
||||
|
||||
14
cmd/init.go
14
cmd/init.go
@@ -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")
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
29
cmd/search.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<Toaster />
|
||||
<TooltipProvider :delay-duration="200">
|
||||
<TooltipProvider :delay-duration="100">
|
||||
<div class="!font-jakarta">
|
||||
<RouterView />
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
31
frontend/src/components/search/SearchHeader.vue
Normal file
31
frontend/src/components/search/SearchHeader.vue
Normal 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>
|
||||
110
frontend/src/components/search/SearchResults.vue
Normal file
110
frontend/src/components/search/SearchResults.vue
Normal 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>
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
50
frontend/src/components/ui/loader/DotLoader.vue
Normal file
50
frontend/src/components/ui/loader/DotLoader.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -1 +1 @@
|
||||
export { default as Loader } from './Loader.vue'
|
||||
export { default as DotLoader } from './DotLoader.vue'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
6
frontend/src/constants/websocket.js
Normal file
6
frontend/src/constants/websocket.js
Normal 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',
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
113
frontend/src/views/SearchView.vue
Normal file
113
frontend/src/views/SearchView.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
@@ -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)'
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
18
internal/search/models/models.go
Normal file
18
internal/search/models/models.go
Normal 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"`
|
||||
}
|
||||
18
internal/search/queries.sql
Normal file
18
internal/search/queries.sql
Normal 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
64
internal/search/search.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user