mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-11-04 14:03:19 +00:00
Compare commits
10 Commits
v0.2.1-alp
...
v0.3.2-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78b8c508d8 | ||
|
|
f17d96f96f | ||
|
|
c75c117a4d | ||
|
|
873d26ccb2 | ||
|
|
71601364ae | ||
|
|
44723fb70d | ||
|
|
67e1230485 | ||
|
|
d58898c60f | ||
|
|
a8dc0a6242 | ||
|
|
3aa144f703 |
@@ -99,6 +99,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
|
||||
g.GET("/api/v1/users/me", auth(handleGetCurrentUser))
|
||||
g.PUT("/api/v1/users/me", auth(handleUpdateCurrentUser))
|
||||
g.GET("/api/v1/users/me/teams", auth(handleGetCurrentUserTeams))
|
||||
g.PUT("/api/v1/users/me/availability", auth(handleUpdateUserAvailability))
|
||||
g.DELETE("/api/v1/users/me/avatar", auth(handleDeleteAvatar))
|
||||
g.GET("/api/v1/users/compact", auth(handleGetUsersCompact))
|
||||
g.GET("/api/v1/users", perm(handleGetUsers, "users:manage"))
|
||||
|
||||
@@ -308,6 +308,11 @@ func initCSAT(db *sqlx.DB) *csat.Manager {
|
||||
return m
|
||||
}
|
||||
|
||||
// initWS inits websocket hub.
|
||||
func initWS(user *user.Manager) *ws.Hub {
|
||||
return ws.NewHub(user)
|
||||
}
|
||||
|
||||
// initTemplates inits template manager.
|
||||
func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants) *tmpl.Manager {
|
||||
var (
|
||||
|
||||
@@ -24,7 +24,7 @@ func install(ctx context.Context, db *sqlx.DB, fs stuffbin.FileSystem, idempoten
|
||||
}
|
||||
|
||||
// Make sure the system user password is strong enough.
|
||||
password := strings.TrimSpace(os.Getenv("LIBREDESK_SYSTEM_USER_PASSWORD"))
|
||||
password := os.Getenv("LIBREDESK_SYSTEM_USER_PASSWORD")
|
||||
if password != "" && !user.IsStrongSystemUserPassword(password) && !schemaInstalled {
|
||||
log.Fatalf("system user password is not strong, %s", user.SystemUserPasswordHint)
|
||||
}
|
||||
|
||||
13
cmd/login.go
13
cmd/login.go
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
umodels "github.com/abhinavxd/libredesk/internal/user/models"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
@@ -11,14 +12,20 @@ import (
|
||||
func handleLogin(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
p = r.RequestCtx.PostArgs()
|
||||
email = string(p.Peek("email"))
|
||||
password = p.Peek("password")
|
||||
email = string(r.RequestCtx.PostArgs().Peek("email"))
|
||||
password = r.RequestCtx.PostArgs().Peek("password")
|
||||
)
|
||||
user, err := app.user.VerifyPassword(email, password)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Set user availability status to online.
|
||||
if err := app.user.UpdateAvailability(user.ID, umodels.Online); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
user.AvailabilityStatus = umodels.Online
|
||||
|
||||
if err := app.auth.SaveSession(amodels.User{
|
||||
ID: user.ID,
|
||||
Email: user.Email.String,
|
||||
|
||||
@@ -36,7 +36,6 @@ import (
|
||||
"github.com/abhinavxd/libredesk/internal/team"
|
||||
"github.com/abhinavxd/libredesk/internal/template"
|
||||
"github.com/abhinavxd/libredesk/internal/user"
|
||||
"github.com/abhinavxd/libredesk/internal/ws"
|
||||
"github.com/knadh/go-i18n"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/knadh/stuffbin"
|
||||
@@ -162,7 +161,6 @@ func main() {
|
||||
messageOutgoingScanInterval = ko.MustDuration("message.message_outoing_scan_interval")
|
||||
slaEvaluationInterval = ko.MustDuration("sla.evaluation_interval")
|
||||
lo = initLogger(appName)
|
||||
wsHub = ws.NewHub()
|
||||
rdb = initRedis()
|
||||
constants = initConstants()
|
||||
i18n = initI18n(fs)
|
||||
@@ -177,6 +175,7 @@ func main() {
|
||||
team = initTeam(db)
|
||||
businessHours = initBusinessHours(db)
|
||||
user = initUser(i18n, db)
|
||||
wsHub = initWS(user)
|
||||
notifier = initNotifier(user)
|
||||
automation = initAutomationEngine(db)
|
||||
sla = initSLA(db, team, settings, businessHours)
|
||||
@@ -193,6 +192,7 @@ func main() {
|
||||
go notifier.Run(ctx)
|
||||
go sla.Run(ctx, slaEvaluationInterval)
|
||||
go media.DeleteUnlinkedMedia(ctx)
|
||||
go user.MonitorAgentAvailability(ctx)
|
||||
|
||||
var app = &App{
|
||||
lo: lo,
|
||||
|
||||
@@ -43,9 +43,7 @@ func tryAuth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
|
||||
// auth makes sure the user is logged in.
|
||||
func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
|
||||
return func(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
)
|
||||
var app = r.Context.(*App)
|
||||
|
||||
// Validate session and fetch user.
|
||||
userSession, err := app.auth.ValidateSession(r)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/dbutil"
|
||||
"github.com/abhinavxd/libredesk/internal/migrations"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/knadh/stuffbin"
|
||||
@@ -28,7 +29,9 @@ type migFunc struct {
|
||||
// migList is the list of available migList ordered by the semver.
|
||||
// Each migration is a Go file in internal/migrations named after the semver.
|
||||
// The functions are named as: v0.7.0 => migrations.V0_7_0() and are idempotent.
|
||||
var migList = []migFunc{}
|
||||
var migList = []migFunc{
|
||||
{"v0.3.0", migrations.V0_3_0},
|
||||
}
|
||||
|
||||
// upgrade upgrades the database to the current version by running SQL migration files
|
||||
// for all version from the last known version to the current one.
|
||||
|
||||
21
cmd/users.go
21
cmd/users.go
@@ -22,7 +22,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
maxAvatarSizeMB = 5
|
||||
maxAvatarSizeMB = 20
|
||||
)
|
||||
|
||||
// handleGetUsers returns all users.
|
||||
@@ -39,9 +39,7 @@ func handleGetUsers(r *fastglue.Request) error {
|
||||
|
||||
// handleGetUsersCompact returns all users in a compact format.
|
||||
func handleGetUsersCompact(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
)
|
||||
var app = r.Context.(*App)
|
||||
agents, err := app.user.GetAllCompact()
|
||||
if err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
|
||||
@@ -66,6 +64,19 @@ func handleGetUser(r *fastglue.Request) error {
|
||||
return r.SendEnvelope(user)
|
||||
}
|
||||
|
||||
// handleUpdateUserAvailability updates the current user availability.
|
||||
func handleUpdateUserAvailability(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
status = string(r.RequestCtx.PostArgs().Peek("status"))
|
||||
)
|
||||
if err := app.user.UpdateAvailability(auser.ID, status); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope("User availability updated successfully.")
|
||||
}
|
||||
|
||||
// handleGetCurrentUserTeams returns the teams of a user.
|
||||
func handleGetCurrentUserTeams(r *fastglue.Request) error {
|
||||
var (
|
||||
@@ -228,7 +239,7 @@ func handleCreateUser(r *fastglue.Request) error {
|
||||
Provider: notifier.ProviderEmail,
|
||||
}); err != nil {
|
||||
app.lo.Error("error sending notification message", "error", err)
|
||||
return r.SendEnvelope("User created successfully, but error sending welcome email.")
|
||||
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "User created successfully, but could not send welcome email.", nil))
|
||||
}
|
||||
}
|
||||
return r.SendEnvelope("User created successfully.")
|
||||
|
||||
@@ -21,7 +21,7 @@ git clone https://github.com/abhinavxd/libredesk.git
|
||||
|
||||
### Running the Dev Environment
|
||||
|
||||
1. Run `make run` to start the libredesk backend dev server on `:9000`.
|
||||
1. Run `make run-backend` to start the libredesk backend dev server on `:9000`.
|
||||
2. Run `make run-frontend` to start the Vue frontend in dev mode using pnpm on `:8000`. Requests are proxied to the backend running on `:9000` check `vite.config.js` for the proxy config.
|
||||
|
||||
---
|
||||
|
||||
@@ -15,9 +15,9 @@ Libredesk is a single binary application that requires postgres and redis to run
|
||||
|
||||
## Docker
|
||||
|
||||
The latest image is available on DockerHub at `libredesk/llibredeskistmonk:latest`
|
||||
The latest image is available on DockerHub at `libredesk/libredesk:latest`
|
||||
|
||||
The recommended method is to download the [docker-compose.yml](https://github.com/abhinavxd/libredesk/blob/master/docker-compose.yml) file, customize it for your environment and then to simply run `docker compose up -d`.
|
||||
The recommended method is to download the [docker-compose.yml](https://github.com/abhinavxd/libredesk/blob/main/docker-compose.yml) file, customize it for your environment and then to simply run `docker compose up -d`.
|
||||
|
||||
```shell
|
||||
# Download the compose file and the sample config file in the current directory.
|
||||
@@ -41,7 +41,7 @@ Go to `http://localhost:9000` and login with the email `System` and the password
|
||||
|
||||
## Compiling from source
|
||||
|
||||
To compile the latest unreleased version (`master` branch):
|
||||
To compile the latest unreleased version (`main` branch):
|
||||
|
||||
1. Make sure `go`, `nodejs`, and `pnpm` are installed on your system.
|
||||
2. `git clone git@github.com:abhinavxd/libredesk.git`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "libredesk",
|
||||
"version": "0.0.0",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -18,6 +18,7 @@
|
||||
"@formkit/auto-animate": "^0.8.2",
|
||||
"@internationalized/date": "^3.5.5",
|
||||
"@radix-icons/vue": "^1.0.0",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/vue-table": "^8.19.2",
|
||||
"@tiptap/extension-image": "^2.5.9",
|
||||
"@tiptap/extension-link": "^2.9.1",
|
||||
@@ -43,6 +44,7 @@
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"vee-validate": "^4.13.2",
|
||||
"vue": "^3.4.37",
|
||||
"vue-dompurify-html": "^5.2.0",
|
||||
"vue-i18n": "9",
|
||||
"vue-letter": "^0.2.0",
|
||||
"vue-picture-cropper": "^0.7.0",
|
||||
|
||||
61
frontend/pnpm-lock.yaml
generated
61
frontend/pnpm-lock.yaml
generated
@@ -17,6 +17,9 @@ importers:
|
||||
'@radix-icons/vue':
|
||||
specifier: ^1.0.0
|
||||
version: 1.0.0(vue@3.5.13(typescript@5.7.3))
|
||||
'@tailwindcss/typography':
|
||||
specifier: ^0.5.16
|
||||
version: 0.5.16(tailwindcss@3.4.17)
|
||||
'@tanstack/vue-table':
|
||||
specifier: ^8.19.2
|
||||
version: 8.20.5(vue@3.5.13(typescript@5.7.3))
|
||||
@@ -92,6 +95,9 @@ importers:
|
||||
vue:
|
||||
specifier: ^3.4.37
|
||||
version: 3.5.13(typescript@5.7.3)
|
||||
vue-dompurify-html:
|
||||
specifier: ^5.2.0
|
||||
version: 5.2.0(vue@3.5.13(typescript@5.7.3))
|
||||
vue-i18n:
|
||||
specifier: '9'
|
||||
version: 9.14.2(vue@3.5.13(typescript@5.7.3))
|
||||
@@ -737,6 +743,11 @@ packages:
|
||||
'@swc/helpers@0.5.15':
|
||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
||||
|
||||
'@tailwindcss/typography@0.5.16':
|
||||
resolution: {integrity: sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==}
|
||||
peerDependencies:
|
||||
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1'
|
||||
|
||||
'@tanstack/table-core@8.20.5':
|
||||
resolution: {integrity: sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -1076,6 +1087,9 @@ packages:
|
||||
'@types/topojson@3.2.6':
|
||||
resolution: {integrity: sha512-ppfdlxjxofWJ66XdLgIlER/85RvpGyfOf8jrWf+3kVIjEatFxEZYD/Ea83jO672Xu1HRzd/ghwlbcZIUNHTskw==}
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
'@types/web-bluetooth@0.0.20':
|
||||
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
|
||||
|
||||
@@ -1718,6 +1732,9 @@ packages:
|
||||
resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
dompurify@3.2.4:
|
||||
resolution: {integrity: sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2317,12 +2334,18 @@ packages:
|
||||
lodash-es@4.17.21:
|
||||
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
|
||||
|
||||
lodash.castarray@4.4.0:
|
||||
resolution: {integrity: sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==}
|
||||
|
||||
lodash.clonedeep@4.5.0:
|
||||
resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==}
|
||||
|
||||
lodash.isequal@4.5.0:
|
||||
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
|
||||
|
||||
lodash.isplainobject@4.0.6:
|
||||
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
|
||||
|
||||
lodash.merge@4.6.2:
|
||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||
|
||||
@@ -2615,6 +2638,10 @@ packages:
|
||||
peerDependencies:
|
||||
postcss: ^8.2.14
|
||||
|
||||
postcss-selector-parser@6.0.10:
|
||||
resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
postcss-selector-parser@6.1.2:
|
||||
resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -3191,6 +3218,11 @@ packages:
|
||||
'@vue/composition-api':
|
||||
optional: true
|
||||
|
||||
vue-dompurify-html@5.2.0:
|
||||
resolution: {integrity: sha512-GX+BStkKEJ8wu/+hU1EK2nu/gzXWhb4XzBu6aowpsuU/3nkvXvZ2jx4nZ9M3jtS/Vu7J7MtFXjc7x3cWQ+zbVQ==}
|
||||
peerDependencies:
|
||||
vue: ^3.0.0
|
||||
|
||||
vue-eslint-parser@9.4.3:
|
||||
resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==}
|
||||
engines: {node: ^14.17.0 || >=16.0.0}
|
||||
@@ -3802,6 +3834,14 @@ snapshots:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@tailwindcss/typography@0.5.16(tailwindcss@3.4.17)':
|
||||
dependencies:
|
||||
lodash.castarray: 4.4.0
|
||||
lodash.isplainobject: 4.0.6
|
||||
lodash.merge: 4.6.2
|
||||
postcss-selector-parser: 6.0.10
|
||||
tailwindcss: 3.4.17
|
||||
|
||||
'@tanstack/table-core@8.20.5': {}
|
||||
|
||||
'@tanstack/virtual-core@3.11.2': {}
|
||||
@@ -4187,6 +4227,9 @@ snapshots:
|
||||
'@types/topojson-simplify': 3.0.3
|
||||
'@types/topojson-specification': 1.0.5
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
optional: true
|
||||
|
||||
'@types/web-bluetooth@0.0.20': {}
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
@@ -4963,6 +5006,10 @@ snapshots:
|
||||
dependencies:
|
||||
esutils: 2.0.3
|
||||
|
||||
dompurify@3.2.4:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.1
|
||||
@@ -5598,10 +5645,14 @@ snapshots:
|
||||
|
||||
lodash-es@4.17.21: {}
|
||||
|
||||
lodash.castarray@4.4.0: {}
|
||||
|
||||
lodash.clonedeep@4.5.0: {}
|
||||
|
||||
lodash.isequal@4.5.0: {}
|
||||
|
||||
lodash.isplainobject@4.0.6: {}
|
||||
|
||||
lodash.merge@4.6.2: {}
|
||||
|
||||
lodash.once@4.1.1: {}
|
||||
@@ -5873,6 +5924,11 @@ snapshots:
|
||||
postcss: 8.4.49
|
||||
postcss-selector-parser: 6.1.2
|
||||
|
||||
postcss-selector-parser@6.0.10:
|
||||
dependencies:
|
||||
cssesc: 3.0.0
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
postcss-selector-parser@6.1.2:
|
||||
dependencies:
|
||||
cssesc: 3.0.0
|
||||
@@ -6530,6 +6586,11 @@ snapshots:
|
||||
dependencies:
|
||||
vue: 3.5.13(typescript@5.7.3)
|
||||
|
||||
vue-dompurify-html@5.2.0(vue@3.5.13(typescript@5.7.3)):
|
||||
dependencies:
|
||||
dompurify: 3.2.4
|
||||
vue: 3.5.13(typescript@5.7.3)
|
||||
|
||||
vue-eslint-parser@9.4.3(eslint@8.57.1):
|
||||
dependencies:
|
||||
debug: 4.4.0(supports-color@8.1.1)
|
||||
|
||||
@@ -48,8 +48,13 @@
|
||||
@delete-view="deleteView"
|
||||
>
|
||||
<div class="flex flex-col h-screen">
|
||||
<AppUpdate />
|
||||
<!-- Show app update only in admin routes -->
|
||||
<AppUpdate v-if="route.path.startsWith('/admin')" />
|
||||
|
||||
<!-- Common header for all pages -->
|
||||
<PageHeader />
|
||||
|
||||
<!-- Main content -->
|
||||
<RouterView class="flex-grow" />
|
||||
</div>
|
||||
<ViewForm v-model:openDialog="openCreateViewForm" v-model:view="view" />
|
||||
@@ -76,6 +81,7 @@ import { useTeamStore } from '@/stores/team'
|
||||
import { useSlaStore } from '@/stores/sla'
|
||||
import { useMacroStore } from '@/stores/macro'
|
||||
import { useTagStore } from '@/stores/tag'
|
||||
import { useIdleDetection } from '@/composables/useIdleDetection'
|
||||
import PageHeader from './components/layout/PageHeader.vue'
|
||||
import ViewForm from '@/features/view/ViewForm.vue'
|
||||
import AppUpdate from '@/components/update/AppUpdate.vue'
|
||||
@@ -113,6 +119,8 @@ const view = ref({})
|
||||
const openCreateViewForm = ref(false)
|
||||
|
||||
initWS()
|
||||
useIdleDetection()
|
||||
|
||||
onMounted(() => {
|
||||
initToaster()
|
||||
listenViewRefresh()
|
||||
@@ -121,8 +129,10 @@ onMounted(() => {
|
||||
|
||||
// initialize data stores
|
||||
const initStores = async () => {
|
||||
if (!userStore.userID) {
|
||||
await userStore.getCurrentUser()
|
||||
}
|
||||
await Promise.allSettled([
|
||||
userStore.getCurrentUser(),
|
||||
getUserViews(),
|
||||
conversationStore.fetchStatuses(),
|
||||
conversationStore.fetchPriorities(),
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
<template>
|
||||
<RouterView />
|
||||
<RouterView />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
</script>
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { toast as sooner } from 'vue-sonner'
|
||||
|
||||
const emitter = useEmitter()
|
||||
|
||||
onMounted(() => {
|
||||
initToaster()
|
||||
})
|
||||
|
||||
const initToaster = () => {
|
||||
emitter.on(EMITTER_EVENTS.SHOW_TOAST, (message) => {
|
||||
if (message.variant === 'destructive') {
|
||||
sooner.error(message.description)
|
||||
} else {
|
||||
sooner.success(message.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -169,6 +169,7 @@ const updateCurrentUser = (data) =>
|
||||
const deleteUserAvatar = () => http.delete('/api/v1/users/me/avatar')
|
||||
const getCurrentUser = () => http.get('/api/v1/users/me')
|
||||
const getCurrentUserTeams = () => http.get('/api/v1/users/me/teams')
|
||||
const updateCurrentUserAvailability = (data) => http.put('/api/v1/users/me/availability', data)
|
||||
const getTags = () => http.get('/api/v1/tags')
|
||||
const upsertTags = (uuid, data) => http.post(`/api/v1/conversations/${uuid}/tags`, data)
|
||||
const updateAssignee = (uuid, assignee_type, data) => http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}`, data)
|
||||
@@ -323,6 +324,7 @@ export default {
|
||||
uploadMedia,
|
||||
updateAssigneeLastSeen,
|
||||
updateUser,
|
||||
updateCurrentUserAvailability,
|
||||
updateAutomationRule,
|
||||
updateAutomationRuleWeights,
|
||||
updateAutomationRulesExecutionMode,
|
||||
|
||||
@@ -1,82 +1,93 @@
|
||||
<template>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<SidebarMenuButton size="lg"
|
||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground p-0">
|
||||
<Avatar class="h-8 w-8 rounded-lg">
|
||||
<AvatarImage :src="userStore.avatar" alt="Abhinav" />
|
||||
<AvatarFallback class="rounded-lg">
|
||||
{{ userStore.getInitials }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-semibold">{{ userStore.getFullName }}</span>
|
||||
<span class="truncate text-xs">{{ userStore.email }}</span>
|
||||
</div>
|
||||
<ChevronsUpDown class="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg" side="bottom"
|
||||
:side-offset="4">
|
||||
<DropdownMenuLabel class="p-0 font-normal">
|
||||
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar class="h-8 w-8 rounded-lg">
|
||||
<AvatarImage :src="userStore.avatar" alt="Abhinav" />
|
||||
<AvatarFallback class="rounded-lg">
|
||||
{{ userStore.getInitials }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-semibold">{{ userStore.getFullName }}</span>
|
||||
<span class="truncate text-xs">{{ userStore.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<router-link to="/account" class="flex items-center">
|
||||
<CircleUserRound size="18" class="mr-2" />
|
||||
Account
|
||||
</router-link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem @click="logout">
|
||||
<LogOut size="18" class="mr-2" />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground p-0"
|
||||
>
|
||||
<Avatar class="h-8 w-8 rounded-lg relative overflow-visible">
|
||||
<AvatarImage :src="userStore.avatar" alt="" class="rounded-lg"/>
|
||||
<AvatarFallback class="rounded-lg">
|
||||
{{ userStore.getInitials }}
|
||||
</AvatarFallback>
|
||||
<div
|
||||
class="absolute bottom-0 right-0 h-2.5 w-2.5 rounded-full border border-background"
|
||||
:class="{
|
||||
'bg-green-500': userStore.user.availability_status === 'online',
|
||||
'bg-amber-500': userStore.user.availability_status === 'away' || userStore.user.availability_status === 'away_manual',
|
||||
'bg-gray-400': userStore.user.availability_status === 'offline'
|
||||
}"
|
||||
></div>
|
||||
</Avatar>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-semibold">{{ userStore.getFullName }}</span>
|
||||
<span class="truncate text-xs">{{ userStore.email }}</span>
|
||||
</div>
|
||||
<ChevronsUpDown class="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
class="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
|
||||
side="bottom"
|
||||
:side-offset="4"
|
||||
>
|
||||
<DropdownMenuLabel class="p-0 font-normal space-y-1">
|
||||
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar class="h-8 w-8 rounded-lg">
|
||||
<AvatarImage :src="userStore.avatar" alt="Abhinav" />
|
||||
<AvatarFallback class="rounded-lg">
|
||||
{{ userStore.getInitials }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-semibold">{{ userStore.getFullName }}</span>
|
||||
<span class="truncate text-xs">{{ userStore.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm justify-between">
|
||||
<span class="text-muted-foreground">Away</span>
|
||||
<Switch
|
||||
:checked="userStore.user.availability_status === 'away' || userStore.user.availability_status === 'away_manual'"
|
||||
@update:checked="(val) => userStore.updateUserAvailability(val ? 'away' : 'online')"
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<router-link to="/account" class="flex items-center">
|
||||
<CircleUserRound size="18" class="mr-2" />
|
||||
Account
|
||||
</router-link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem @click="logout">
|
||||
<LogOut size="18" class="mr-2" />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
SidebarMenuButton,
|
||||
} from '@/components/ui/sidebar'
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from '@/components/ui/avatar'
|
||||
import {
|
||||
ChevronsUpDown,
|
||||
CircleUserRound,
|
||||
LogOut,
|
||||
} from 'lucide-vue-next'
|
||||
import { SidebarMenuButton } from '@/components/ui/sidebar'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { ChevronsUpDown, CircleUserRound, LogOut } from 'lucide-vue-next'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
const userStore = useUserStore()
|
||||
|
||||
const logout = () => {
|
||||
window.location.href = '/logout'
|
||||
window.location.href = '/logout'
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
43
frontend/src/composables/useIdleDetection.js
Normal file
43
frontend/src/composables/useIdleDetection.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { debounce } from '@/utils/debounce'
|
||||
|
||||
export function useIdleDetection () {
|
||||
const userStore = useUserStore()
|
||||
// 4 minutes
|
||||
const AWAY_THRESHOLD = 4 * 60 * 1000
|
||||
// 1 minute
|
||||
const CHECK_INTERVAL = 60 * 1000
|
||||
const lastActivity = ref(Date.now())
|
||||
const timer = ref(null)
|
||||
|
||||
function resetTimer () {
|
||||
if (userStore.user.availability_status === 'away' || userStore.user.availability_status === 'offline') {
|
||||
userStore.updateUserAvailability('online', false)
|
||||
}
|
||||
lastActivity.value = Date.now()
|
||||
}
|
||||
|
||||
const debouncedResetTimer = debounce(resetTimer, 200)
|
||||
|
||||
function checkIdle () {
|
||||
if (Date.now() - lastActivity.value > AWAY_THRESHOLD &&
|
||||
userStore.user.availability_status === 'online') {
|
||||
userStore.updateUserAvailability('away', false)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('mousemove', debouncedResetTimer)
|
||||
window.addEventListener('keypress', debouncedResetTimer)
|
||||
window.addEventListener('click', debouncedResetTimer)
|
||||
timer.value = setInterval(checkIdle, CHECK_INTERVAL)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('mousemove', debouncedResetTimer)
|
||||
window.removeEventListener('keypress', debouncedResetTimer)
|
||||
window.removeEventListener('click', debouncedResetTimer)
|
||||
clearInterval(timer.value)
|
||||
})
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
<CommandGroup
|
||||
heading="Conversations"
|
||||
value="conversations"
|
||||
v-if="nestedCommand === null && conversationStore.current"
|
||||
v-if="nestedCommand === null && conversationStore.hasConversationOpen"
|
||||
>
|
||||
<CommandItem value="conv-snooze" @select="setNestedCommand('snooze')"> Snooze </CommandItem>
|
||||
<CommandItem value="conv-resolve" @select="resolveConversation"> Resolve </CommandItem>
|
||||
@@ -45,7 +45,6 @@
|
||||
:data-index="index"
|
||||
@select="handleApplyMacro(macro)"
|
||||
class="px-3 py-2 rounded-md cursor-pointer transition-all duration-200 hover:bg-primary/10 hover:text-primary"
|
||||
:class="{ 'bg-primary/5 text-primary': selectedMacroIndex === index }"
|
||||
>
|
||||
<div class="flex items-center space-x-2 justify-start">
|
||||
<Zap :size="14" class="text-primary" />
|
||||
@@ -59,7 +58,7 @@
|
||||
<div v-if="replyContent" class="space-y-1">
|
||||
<p class="text-xs font-semibold text-primary">Reply Preview</p>
|
||||
<div
|
||||
class="w-full min-h-200 p-2 bg-muted/50 rounded-md overflow-auto shadow-sm"
|
||||
class="w-full min-h-200 p-2 bg-muted/50 rounded-md overflow-auto shadow-sm prose-sm"
|
||||
v-html="replyContent"
|
||||
/>
|
||||
</div>
|
||||
@@ -219,7 +218,9 @@ watch([Meta_K, Ctrl_K], ([mac, win]) => {
|
||||
const highlightedMacro = ref(null)
|
||||
|
||||
function handleApplyMacro(macro) {
|
||||
conversationStore.setMacro(macro)
|
||||
// Create a deep copy.
|
||||
const plainMacro = JSON.parse(JSON.stringify(macro))
|
||||
conversationStore.setMacro(plainMacro)
|
||||
handleOpenChange()
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<div class="flex flex-col flex-grow overflow-hidden">
|
||||
<MessageList class="flex-1 overflow-y-auto" />
|
||||
<div class="sticky bottom-0">
|
||||
<ReplyBox class="h-max" />
|
||||
<ReplyBox class="h-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="max-h-[600px] overflow-y-auto">
|
||||
<div class="editor-wrapper h-full overflow-y-auto">
|
||||
<BubbleMenu
|
||||
:editor="editor"
|
||||
:tippy-options="{ duration: 100 }"
|
||||
@@ -179,13 +179,20 @@ watchEffect(() => {
|
||||
|
||||
watch(
|
||||
() => props.contentToSet,
|
||||
(newContent) => {
|
||||
if (newContent === '') {
|
||||
editor.value?.commands.clearContent()
|
||||
} else {
|
||||
editor.value?.commands.setContent(newContent, true)
|
||||
(newContentData) => {
|
||||
if (!newContentData) return
|
||||
try {
|
||||
const parsedData = JSON.parse(newContentData)
|
||||
const content = parsedData.content
|
||||
if (content === '') {
|
||||
editor.value?.commands.clearContent()
|
||||
} else {
|
||||
editor.value?.commands.setContent(content, true)
|
||||
}
|
||||
editor.value?.commands.focus()
|
||||
} catch (e) {
|
||||
console.error('Error parsing content data', e)
|
||||
}
|
||||
editor.value?.commands.focus()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -243,22 +250,26 @@ onUnmounted(() => {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
// Editor height
|
||||
.ProseMirror {
|
||||
min-height: 80px !important;
|
||||
max-height: 60% !important;
|
||||
overflow-y: scroll !important;
|
||||
// Ensure the parent div has a proper height
|
||||
.editor-wrapper div[aria-expanded='false'] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.fullscreen-tiptap-editor {
|
||||
@apply p-0;
|
||||
.ProseMirror {
|
||||
min-height: 600px !important;
|
||||
width: 90%;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
// Ensure the editor content has a proper height and breaks words
|
||||
.tiptap.ProseMirror {
|
||||
flex: 1;
|
||||
min-height: 70px;
|
||||
overflow-y: auto;
|
||||
word-wrap: break-word !important;
|
||||
overflow-wrap: break-word !important;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
// Anchor tag styling
|
||||
.tiptap {
|
||||
a {
|
||||
color: #0066cc;
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap px-2 py-1">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div class="flex flex-wrap">
|
||||
<div class="flex flex-wrap">
|
||||
<div
|
||||
v-for="action in actions"
|
||||
:key="action.type"
|
||||
class="flex items-center bg-white border border-gray-200 rounded shadow-sm transition-all duration-300 ease-in-out hover:shadow-md group"
|
||||
class="flex items-center bg-white border border-gray-200 rounded shadow-sm transition-all duration-300 ease-in-out hover:shadow-md group gap-2 py-1"
|
||||
>
|
||||
<div class="flex items-center space-x-2 px-3 py-2">
|
||||
<div class="flex items-center space-x-2 px-2 ">
|
||||
<component
|
||||
:is="getIcon(action.type)"
|
||||
size="16"
|
||||
class="text-primary group-hover:text-primary"
|
||||
class="text-gray-500 text-primary group-hover:text-primary"
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
@@ -27,7 +27,7 @@
|
||||
</div>
|
||||
<button
|
||||
@click.stop="onRemove(action)"
|
||||
class="p-2 text-gray-400 hover:text-red-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50 rounded transition-colors duration-300 ease-in-out"
|
||||
class="pr-2 text-gray-400 hover:text-red-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50 rounded transition-colors duration-300 ease-in-out"
|
||||
title="Remove action"
|
||||
>
|
||||
<X size="14" />
|
||||
|
||||
@@ -3,328 +3,137 @@
|
||||
<!-- Fullscreen editor -->
|
||||
<Dialog :open="isEditorFullscreen" @update:open="isEditorFullscreen = false">
|
||||
<DialogContent
|
||||
class="max-w-[70%] max-h-[70%] h-[90%] w-full bg-card text-card-foreground px-4 py-4"
|
||||
class="max-w-[70%] max-h-[70%] h-[70%] bg-card text-card-foreground p-4 flex flex-col"
|
||||
@escapeKeyDown="isEditorFullscreen = false"
|
||||
hide-close-button="true"
|
||||
:hide-close-button="true"
|
||||
>
|
||||
<div v-if="isEditorFullscreen" class="h-full flex flex-col">
|
||||
<!-- Message type toggle -->
|
||||
<div class="flex justify-between items-center border-b border-border pb-4">
|
||||
<Tabs v-model="messageType" class="rounded-lg">
|
||||
<TabsList class="bg-muted p-1 rounded-lg">
|
||||
<TabsTrigger
|
||||
value="reply"
|
||||
class="px-3 py-1 rounded-lg transition-colors duration-200"
|
||||
:class="{ 'bg-background text-foreground': messageType === 'reply' }"
|
||||
>
|
||||
Reply
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="private_note"
|
||||
class="px-3 py-1 rounded-lg transition-colors duration-200"
|
||||
:class="{ 'bg-background text-foreground': messageType === 'private_note' }"
|
||||
>
|
||||
Private note
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<span
|
||||
class="text-muted-foreground hover:text-foreground transition-colors duration-200 cursor-pointer"
|
||||
variant="ghost"
|
||||
@click="isEditorFullscreen = false"
|
||||
>
|
||||
<Minimize2 size="18" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- CC and BCC fields -->
|
||||
<div class="space-y-3 p-4 border-b border-border" v-if="messageType === 'reply'">
|
||||
<div class="flex items-center space-x-2">
|
||||
<label class="w-12 text-sm font-medium text-muted-foreground">CC:</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Email addresses separated by comma"
|
||||
v-model="cc"
|
||||
class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring"
|
||||
@blur="validateEmails('cc')"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
@click="hideBcc"
|
||||
class="text-sm bg-secondary text-secondary-foreground hover:bg-secondary/80"
|
||||
>
|
||||
{{ showBcc ? 'Remove BCC' : 'BCC' }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="showBcc" class="flex items-center space-x-2">
|
||||
<label class="w-12 text-sm font-medium text-muted-foreground">BCC:</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Email addresses separated by comma"
|
||||
v-model="bcc"
|
||||
class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring"
|
||||
@blur="validateEmails('bcc')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="emailErrors.length > 0"
|
||||
class="mb-4 px-2 py-1 bg-destructive/10 border border-destructive text-destructive rounded"
|
||||
>
|
||||
<p v-for="error in emailErrors" :key="error" class="text-sm">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Main Editor -->
|
||||
<div class="flex-grow overflow-y-auto p-2">
|
||||
<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"
|
||||
class="h-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Macro preview -->
|
||||
<MacroActionsPreview
|
||||
v-if="conversationStore.conversation?.macro?.actions?.length > 0"
|
||||
:actions="conversationStore.conversation.macro.actions"
|
||||
:onRemove="conversationStore.removeMacroAction"
|
||||
class="mt-4"
|
||||
/>
|
||||
|
||||
<!-- Attachments preview -->
|
||||
<AttachmentsPreview
|
||||
:attachments="attachments"
|
||||
:uploadingFiles="uploadingFiles"
|
||||
:onDelete="handleOnFileDelete"
|
||||
v-if="attachments.length > 0 || uploadingFiles.length > 0"
|
||||
class="mt-4"
|
||||
/>
|
||||
|
||||
<!-- Bottom menu bar -->
|
||||
<ReplyBoxBottomMenuBar
|
||||
class="mt-4 pt-4"
|
||||
:handleFileUpload="handleFileUpload"
|
||||
:handleInlineImageUpload="handleInlineImageUpload"
|
||||
:isBold="isBold"
|
||||
:isItalic="isItalic"
|
||||
:isSending="isSending"
|
||||
@toggleBold="toggleBold"
|
||||
@toggleItalic="toggleItalic"
|
||||
:enableSend="enableSend"
|
||||
:handleSend="handleSend"
|
||||
@emojiSelect="handleEmojiSelect"
|
||||
/>
|
||||
</div>
|
||||
<ReplyBoxContent
|
||||
v-if="isEditorFullscreen"
|
||||
:isFullscreen="true"
|
||||
:aiPrompts="aiPrompts"
|
||||
:isSending="isSending"
|
||||
:uploadingFiles="uploadingFiles"
|
||||
:clearEditorContent="clearEditorContent"
|
||||
:htmlContent="htmlContent"
|
||||
:textContent="textContent"
|
||||
:selectedText="selectedText"
|
||||
:isBold="isBold"
|
||||
:isItalic="isItalic"
|
||||
:cursorPosition="cursorPosition"
|
||||
:contentToSet="contentToSet"
|
||||
:cc="cc"
|
||||
:bcc="bcc"
|
||||
:emailErrors="emailErrors"
|
||||
:messageType="messageType"
|
||||
:showBcc="showBcc"
|
||||
@update:htmlContent="htmlContent = $event"
|
||||
@update:textContent="textContent = $event"
|
||||
@update:selectedText="selectedText = $event"
|
||||
@update:isBold="isBold = $event"
|
||||
@update:isItalic="isItalic = $event"
|
||||
@update:cursorPosition="cursorPosition = $event"
|
||||
@toggleFullscreen="isEditorFullscreen = false"
|
||||
@update:messageType="messageType = $event"
|
||||
@update:cc="cc = $event"
|
||||
@update:bcc="bcc = $event"
|
||||
@update:showBcc="showBcc = $event"
|
||||
@updateEmailErrors="emailErrors = $event"
|
||||
@send="processSend"
|
||||
@fileUpload="handleFileUpload"
|
||||
@inlineImageUpload="handleInlineImageUpload"
|
||||
@fileDelete="handleOnFileDelete"
|
||||
@aiPromptSelected="handleAiPromptSelected"
|
||||
class="h-full flex-grow"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- Main Editor non-fullscreen -->
|
||||
<div class="bg-card text-card-foreground box px-2 pt-2 m-2">
|
||||
<div v-if="!isEditorFullscreen" class="">
|
||||
<!-- Message type toggle -->
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<Tabs v-model="messageType" class="rounded-lg">
|
||||
<TabsList class="bg-muted p-1 rounded-lg">
|
||||
<TabsTrigger
|
||||
value="reply"
|
||||
class="px-3 py-1 rounded-lg transition-colors duration-200"
|
||||
:class="{ 'bg-background text-foreground': messageType === 'reply' }"
|
||||
>
|
||||
Reply
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="private_note"
|
||||
class="px-3 py-1 rounded-lg transition-colors duration-200"
|
||||
:class="{ 'bg-background text-foreground': messageType === 'private_note' }"
|
||||
>
|
||||
Private note
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<span
|
||||
class="text-muted-foreground hover:text-foreground transition-colors duration-200 cursor-pointer mr-2"
|
||||
variant="ghost"
|
||||
@click="isEditorFullscreen = true"
|
||||
>
|
||||
<Maximize2 size="15" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 mb-4" v-if="messageType === 'reply'">
|
||||
<div class="flex items-center space-x-2">
|
||||
<label class="w-12 text-sm font-medium text-muted-foreground">CC:</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Email addresses separated by comma"
|
||||
v-model="cc"
|
||||
class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring"
|
||||
@blur="validateEmails('cc')"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
@click="hideBcc"
|
||||
class="text-sm bg-secondary text-secondary-foreground hover:bg-secondary/80"
|
||||
>
|
||||
{{ showBcc ? 'Remove BCC' : 'BCC' }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="showBcc" class="flex items-center space-x-2">
|
||||
<label class="w-12 text-sm font-medium text-muted-foreground">BCC:</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Email addresses separated by comma"
|
||||
v-model="bcc"
|
||||
class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring"
|
||||
@blur="validateEmails('bcc')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="emailErrors.length > 0"
|
||||
class="mb-4 px-2 py-1 bg-destructive/10 border border-destructive text-destructive rounded"
|
||||
>
|
||||
<p v-for="error in emailErrors" :key="error" class="text-sm">{{ error }}</p>
|
||||
</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"
|
||||
:uploadingFiles="uploadingFiles"
|
||||
:onDelete="handleOnFileDelete"
|
||||
v-if="attachments.length > 0 || uploadingFiles.length > 0"
|
||||
class="mt-4"
|
||||
/>
|
||||
|
||||
<!-- Bottom menu bar -->
|
||||
<ReplyBoxBottomMenuBar
|
||||
class="mt-1"
|
||||
:handleFileUpload="handleFileUpload"
|
||||
:handleInlineImageUpload="handleInlineImageUpload"
|
||||
:isBold="isBold"
|
||||
:isItalic="isItalic"
|
||||
:isSending="isSending"
|
||||
@toggleBold="toggleBold"
|
||||
@toggleItalic="toggleItalic"
|
||||
:enableSend="enableSend"
|
||||
:handleSend="handleSend"
|
||||
@emojiSelect="handleEmojiSelect"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="bg-card text-card-foreground box m-2 px-2 pt-2 flex flex-col"
|
||||
v-if="!isEditorFullscreen"
|
||||
>
|
||||
<ReplyBoxContent
|
||||
:isFullscreen="false"
|
||||
:aiPrompts="aiPrompts"
|
||||
:isSending="isSending"
|
||||
:uploadingFiles="uploadingFiles"
|
||||
:clearEditorContent="clearEditorContent"
|
||||
:htmlContent="htmlContent"
|
||||
:textContent="textContent"
|
||||
:selectedText="selectedText"
|
||||
:isBold="isBold"
|
||||
:isItalic="isItalic"
|
||||
:cursorPosition="cursorPosition"
|
||||
:contentToSet="contentToSet"
|
||||
:cc="cc"
|
||||
:bcc="bcc"
|
||||
:emailErrors="emailErrors"
|
||||
:messageType="messageType"
|
||||
:showBcc="showBcc"
|
||||
@update:htmlContent="htmlContent = $event"
|
||||
@update:textContent="textContent = $event"
|
||||
@update:selectedText="selectedText = $event"
|
||||
@update:isBold="isBold = $event"
|
||||
@update:isItalic="isItalic = $event"
|
||||
@update:cursorPosition="cursorPosition = $event"
|
||||
@toggleFullscreen="isEditorFullscreen = true"
|
||||
@update:messageType="messageType = $event"
|
||||
@update:cc="cc = $event"
|
||||
@update:bcc="bcc = $event"
|
||||
@update:showBcc="showBcc = $event"
|
||||
@updateEmailErrors="emailErrors = $event"
|
||||
@send="processSend"
|
||||
@fileUpload="handleFileUpload"
|
||||
@inlineImageUpload="handleInlineImageUpload"
|
||||
@fileDelete="handleOnFileDelete"
|
||||
@aiPromptSelected="handleAiPromptSelected"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, nextTick, watch } from 'vue'
|
||||
import { ref, onMounted, nextTick, watch, computed } from 'vue'
|
||||
import { transformImageSrcToCID } from '@/utils/strings'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { Maximize2, Minimize2 } from 'lucide-vue-next'
|
||||
import api from '@/api'
|
||||
|
||||
import Editor from './ConversationTextEditor.vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import AttachmentsPreview from '@/features/conversation/message/attachment/AttachmentsPreview.vue'
|
||||
import MacroActionsPreview from '@/features/conversation/MacroActionsPreview.vue'
|
||||
import ReplyBoxBottomMenuBar from '@/features/conversation/ReplyBoxMenuBar.vue'
|
||||
import ReplyBoxContent from '@/features/conversation/ReplyBoxContent.vue'
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
const emitter = useEmitter()
|
||||
const insertContent = ref(null)
|
||||
const setInlineImage = ref(null)
|
||||
|
||||
// Shared state between the two editor components.
|
||||
const clearEditorContent = ref(false)
|
||||
const isEditorFullscreen = ref(false)
|
||||
const isSending = ref(false)
|
||||
const cursorPosition = ref(0)
|
||||
const selectedText = ref('')
|
||||
const htmlContent = ref('')
|
||||
const textContent = ref('')
|
||||
const contentToSet = ref('')
|
||||
const isBold = ref(false)
|
||||
const isItalic = ref(false)
|
||||
const messageType = ref('reply')
|
||||
const showBcc = ref(false)
|
||||
const cc = ref('')
|
||||
const bcc = ref('')
|
||||
const showBcc = ref(false)
|
||||
const emailErrors = ref([])
|
||||
const aiPrompts = ref([])
|
||||
const uploadingFiles = ref([])
|
||||
const editorPlaceholder = 'Press Enter to add a new line; Press Ctrl + Enter to send.'
|
||||
const htmlContent = ref('')
|
||||
const textContent = ref('')
|
||||
const selectedText = ref('')
|
||||
const isBold = ref(false)
|
||||
const isItalic = ref(false)
|
||||
const cursorPosition = ref(0)
|
||||
const contentToSet = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchAiPrompts()
|
||||
})
|
||||
|
||||
const hideBcc = () => {
|
||||
showBcc.value = !showBcc.value
|
||||
}
|
||||
|
||||
watch(
|
||||
() => conversationStore.currentCC,
|
||||
(newVal) => {
|
||||
cc.value = newVal?.join(', ') || ''
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => conversationStore.currentBCC,
|
||||
(newVal) => {
|
||||
const newBcc = newVal?.join(', ') || ''
|
||||
bcc.value = newBcc
|
||||
if (newBcc.length == 0) {
|
||||
showBcc.value = false
|
||||
} else {
|
||||
showBcc.value = true
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
|
||||
/**
|
||||
* Fetches AI prompts from the server.
|
||||
*/
|
||||
const fetchAiPrompts = async () => {
|
||||
try {
|
||||
const resp = await api.getAiPrompts()
|
||||
@@ -338,13 +147,22 @@ const fetchAiPrompts = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the AI prompt selection event.
|
||||
* Sends the selected prompt key and the current text content to the server for completion.
|
||||
* Sets the response as the new content in the editor.
|
||||
* @param {String} key - The key of the selected AI prompt
|
||||
*/
|
||||
const handleAiPromptSelected = async (key) => {
|
||||
try {
|
||||
const resp = await api.aiCompletion({
|
||||
prompt_key: key,
|
||||
content: selectedText.value
|
||||
content: textContent.value
|
||||
})
|
||||
contentToSet.value = JSON.stringify({
|
||||
content: resp.data.data.replace(/\n/g, '<br>'),
|
||||
timestamp: Date.now()
|
||||
})
|
||||
contentToSet.value = resp.data.data.replace(/\n/g, '<br>')
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Error',
|
||||
@@ -354,33 +172,11 @@ const handleAiPromptSelected = async (key) => {
|
||||
}
|
||||
}
|
||||
|
||||
const toggleBold = () => {
|
||||
isBold.value = !isBold.value
|
||||
}
|
||||
|
||||
const toggleItalic = () => {
|
||||
isItalic.value = !isItalic.value
|
||||
}
|
||||
|
||||
const attachments = computed(() => {
|
||||
return conversationStore.conversation.mediaFiles.filter(
|
||||
(upload) => upload.disposition === 'attachment'
|
||||
)
|
||||
})
|
||||
|
||||
const enableSend = computed(() => {
|
||||
return (
|
||||
(textContent.value.trim().length > 0 ||
|
||||
conversationStore.conversation?.macro?.actions?.length > 0) &&
|
||||
emailErrors.value.length === 0 &&
|
||||
!uploadingFiles.value.length
|
||||
)
|
||||
})
|
||||
|
||||
const hasTextContent = computed(() => {
|
||||
return textContent.value.trim().length > 0
|
||||
})
|
||||
|
||||
/**
|
||||
* Handles the file upload process when files are selected.
|
||||
* Uploads each file to the server and adds them to the conversation's mediaFiles.
|
||||
* @param {Event} event - The file input change event containing selected files
|
||||
*/
|
||||
const handleFileUpload = (event) => {
|
||||
const files = Array.from(event.target.files)
|
||||
uploadingFiles.value = files
|
||||
@@ -407,6 +203,7 @@ const handleFileUpload = (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Inline image upload is not supported yet.
|
||||
const handleInlineImageUpload = (event) => {
|
||||
for (const file of event.target.files) {
|
||||
api
|
||||
@@ -416,12 +213,13 @@ const handleInlineImageUpload = (event) => {
|
||||
linked_model: 'messages'
|
||||
})
|
||||
.then((resp) => {
|
||||
setInlineImage.value = {
|
||||
const imageData = {
|
||||
src: resp.data.data.url,
|
||||
alt: resp.data.data.filename,
|
||||
title: resp.data.data.uuid
|
||||
}
|
||||
conversationStore.conversation.mediaFiles.push(resp.data.data)
|
||||
return imageData
|
||||
})
|
||||
.catch((error) => {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
@@ -433,44 +231,23 @@ const handleInlineImageUpload = (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
const validateEmails = (field) => {
|
||||
const emails = field === 'cc' ? cc.value : bcc.value
|
||||
const emailList = emails
|
||||
.split(',')
|
||||
.map((e) => e.trim())
|
||||
.filter((e) => e !== '')
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
const invalidEmails = emailList.filter((email) => !emailRegex.test(email))
|
||||
|
||||
// Remove any existing errors for this field
|
||||
emailErrors.value = emailErrors.value.filter(
|
||||
(error) => !error.startsWith(`Invalid email(s) in ${field.toUpperCase()}`)
|
||||
)
|
||||
|
||||
// Add new error if there are invalid emails
|
||||
if (invalidEmails.length > 0) {
|
||||
emailErrors.value.push(
|
||||
`Invalid email(s) in ${field.toUpperCase()}: ${invalidEmails.join(', ')}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
if (emailErrors.value.length > 0) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Error',
|
||||
variant: 'destructive',
|
||||
description: 'Please correct the email errors before sending.'
|
||||
})
|
||||
return
|
||||
}
|
||||
/**
|
||||
* Returns true if the editor has text content.
|
||||
*/
|
||||
const hasTextContent = computed(() => {
|
||||
return textContent.value.trim().length > 0
|
||||
})
|
||||
|
||||
/**
|
||||
* Processes the send action.
|
||||
*/
|
||||
const processSend = async () => {
|
||||
isEditorFullscreen.value = false
|
||||
try {
|
||||
isSending.value = true
|
||||
|
||||
// Send message if there is text content in the editor.
|
||||
if (hasTextContent.value) {
|
||||
if (hasTextContent.value > 0) {
|
||||
// Replace inline image url with cid.
|
||||
const message = transformImageSrcToCID(htmlContent.value)
|
||||
|
||||
@@ -490,7 +267,7 @@ const handleSend = async () => {
|
||||
)
|
||||
|
||||
await api.sendMessage(conversationStore.current.uuid, {
|
||||
private: messageType.value === 'private_note',
|
||||
private: messageType.value === 'private',
|
||||
message: message,
|
||||
attachments: conversationStore.conversation.mediaFiles.map((file) => file.id),
|
||||
// Convert email addresses to array and remove empty strings.
|
||||
@@ -498,7 +275,7 @@ const handleSend = async () => {
|
||||
.split(',')
|
||||
.map((email) => email.trim())
|
||||
.filter((email) => email),
|
||||
bcc: showBcc.value
|
||||
bcc: bcc.value
|
||||
? bcc.value
|
||||
.split(',')
|
||||
.map((email) => email.trim())
|
||||
@@ -524,6 +301,7 @@ const handleSend = async () => {
|
||||
} finally {
|
||||
isSending.value = false
|
||||
clearEditorContent.value = true
|
||||
// Reset media and macro in conversation store.
|
||||
conversationStore.resetMacro()
|
||||
conversationStore.resetMediaFiles()
|
||||
emailErrors.value = []
|
||||
@@ -531,33 +309,64 @@ const handleSend = async () => {
|
||||
clearEditorContent.value = false
|
||||
})
|
||||
}
|
||||
// Update assignee last seen timestamp.
|
||||
api.updateAssigneeLastSeen(conversationStore.current.uuid)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the file delete event.
|
||||
* Removes the file from the conversation's mediaFiles.
|
||||
* @param {String} uuid - The UUID of the file to delete
|
||||
*/
|
||||
const handleOnFileDelete = (uuid) => {
|
||||
conversationStore.conversation.mediaFiles = conversationStore.conversation.mediaFiles.filter(
|
||||
(item) => item.uuid !== uuid
|
||||
)
|
||||
}
|
||||
|
||||
const handleEmojiSelect = (emoji) => {
|
||||
insertContent.value = undefined
|
||||
// Force reactivity so the user can select the same emoji multiple times
|
||||
nextTick(() => (insertContent.value = emoji))
|
||||
}
|
||||
|
||||
// Watch for changes in macro content and update editor content.
|
||||
/**
|
||||
* Watches for changes in the conversation's macro and updates the editor content with the macro content.
|
||||
*/
|
||||
watch(
|
||||
() => conversationStore.conversation.macro,
|
||||
() => {
|
||||
// hack: Quill editor adds <p><br></p> replace with <p></p>
|
||||
// Maybe use some other editor that doesn't add this?
|
||||
if (conversationStore.conversation?.macro?.message_content) {
|
||||
contentToSet.value = conversationStore.conversation.macro.message_content.replace(
|
||||
const contentToRender = conversationStore.conversation.macro.message_content.replace(
|
||||
/<p><br><\/p>/g,
|
||||
'<p></p>'
|
||||
)
|
||||
// Add timestamp to ensure the watcher detects the change even for identical content,
|
||||
// As user can send the same macro multiple times.
|
||||
contentToSet.value = JSON.stringify({
|
||||
content: contentToRender,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// Initialize cc and bcc from conversation store
|
||||
watch(
|
||||
() => conversationStore.currentCC,
|
||||
(newVal) => {
|
||||
cc.value = newVal?.join(', ') || ''
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => conversationStore.currentBCC,
|
||||
(newVal) => {
|
||||
const newBcc = newVal?.join(', ') || ''
|
||||
bcc.value = newBcc
|
||||
// Only show BCC field if it has content
|
||||
if (newBcc.length > 0) {
|
||||
showBcc.value = true
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
305
frontend/src/features/conversation/ReplyBoxContent.vue
Normal file
305
frontend/src/features/conversation/ReplyBoxContent.vue
Normal file
@@ -0,0 +1,305 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full max-h-[600px]">
|
||||
<!-- Message type toggle -->
|
||||
<div
|
||||
class="flex justify-between items-center"
|
||||
:class="{ 'mb-4': !isFullscreen, 'border-b border-border pb-4': isFullscreen }"
|
||||
>
|
||||
<Tabs v-model="messageType" class="rounded-lg">
|
||||
<TabsList class="bg-muted p-1 rounded-lg">
|
||||
<TabsTrigger
|
||||
value="reply"
|
||||
class="px-3 py-1 rounded-lg transition-colors duration-200"
|
||||
:class="{ 'bg-background text-foreground': messageType === 'reply' }"
|
||||
>
|
||||
Reply
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="private_note"
|
||||
class="px-3 py-1 rounded-lg transition-colors duration-200"
|
||||
:class="{ 'bg-background text-foreground': messageType === 'private_note' }"
|
||||
>
|
||||
Private note
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<span
|
||||
class="text-muted-foreground hover:text-foreground transition-colors duration-200 cursor-pointer"
|
||||
variant="ghost"
|
||||
@click="toggleFullscreen"
|
||||
>
|
||||
<component
|
||||
:is="isFullscreen ? Minimize2 : Maximize2"
|
||||
:size="isFullscreen ? '18' : '15'"
|
||||
:class="{ 'mr-2': !isFullscreen }"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- CC and BCC fields -->
|
||||
<div
|
||||
:class="['space-y-3', isFullscreen ? 'p-4 border-b border-border' : 'mb-4']"
|
||||
v-if="messageType === 'reply'"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<label class="w-12 text-sm font-medium text-muted-foreground">CC:</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Email addresses separated by comma"
|
||||
v-model="cc"
|
||||
class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring"
|
||||
@blur="validateEmails('cc')"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
@click="toggleBcc"
|
||||
class="text-sm bg-secondary text-secondary-foreground hover:bg-secondary/80"
|
||||
>
|
||||
{{ showBcc ? 'Remove BCC' : 'BCC' }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="showBcc" class="flex items-center space-x-2">
|
||||
<label class="w-12 text-sm font-medium text-muted-foreground">BCC:</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Email addresses separated by comma"
|
||||
v-model="bcc"
|
||||
class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring"
|
||||
@blur="validateEmails('bcc')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CC and BCC field validation errors -->
|
||||
<div
|
||||
v-if="emailErrors.length > 0"
|
||||
class="mb-4 px-2 py-1 bg-destructive/10 border border-destructive text-destructive rounded"
|
||||
>
|
||||
<p v-for="error in emailErrors" :key="error" class="text-sm">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Main tiptap editor -->
|
||||
<div class="flex-grow flex flex-col overflow-hidden">
|
||||
<Editor
|
||||
v-model:selectedText="selectedText"
|
||||
v-model:isBold="isBold"
|
||||
v-model:isItalic="isItalic"
|
||||
v-model:htmlContent="htmlContent"
|
||||
v-model:textContent="textContent"
|
||||
v-model:cursorPosition="cursorPosition"
|
||||
:placeholder="editorPlaceholder"
|
||||
:aiPrompts="aiPrompts"
|
||||
@aiPromptSelected="handleAiPromptSelected"
|
||||
:contentToSet="contentToSet"
|
||||
@send="handleSend"
|
||||
:clearContent="clearEditorContent"
|
||||
:setInlineImage="setInlineImage"
|
||||
:insertContent="insertContent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Macro preview -->
|
||||
<MacroActionsPreview
|
||||
v-if="conversationStore.conversation?.macro?.actions?.length > 0"
|
||||
:actions="conversationStore.conversation.macro.actions"
|
||||
:onRemove="conversationStore.removeMacroAction"
|
||||
class="mt-2"
|
||||
/>
|
||||
|
||||
<!-- Attachments preview -->
|
||||
<AttachmentsPreview
|
||||
:attachments="attachments"
|
||||
:uploadingFiles="uploadingFiles"
|
||||
:onDelete="handleOnFileDelete"
|
||||
v-if="attachments.length > 0 || uploadingFiles.length > 0"
|
||||
class="mt-2"
|
||||
/>
|
||||
|
||||
<!-- Editor menu bar with send button -->
|
||||
<ReplyBoxMenuBar
|
||||
class="mt-1 shrink-0"
|
||||
:handleFileUpload="handleFileUpload"
|
||||
:handleInlineImageUpload="handleInlineImageUpload"
|
||||
:isBold="isBold"
|
||||
:isItalic="isItalic"
|
||||
:isSending="isSending"
|
||||
@toggleBold="toggleBold"
|
||||
@toggleItalic="toggleItalic"
|
||||
:enableSend="enableSend"
|
||||
:handleSend="handleSend"
|
||||
@emojiSelect="handleEmojiSelect"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { Maximize2, Minimize2 } from 'lucide-vue-next'
|
||||
import Editor from './ConversationTextEditor.vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import AttachmentsPreview from '@/features/conversation/message/attachment/AttachmentsPreview.vue'
|
||||
import MacroActionsPreview from '@/features/conversation/MacroActionsPreview.vue'
|
||||
import ReplyBoxMenuBar from '@/features/conversation/ReplyBoxMenuBar.vue'
|
||||
|
||||
// Define models for two-way binding
|
||||
const messageType = defineModel('messageType', { default: 'reply' })
|
||||
const cc = defineModel('cc', { default: '' })
|
||||
const bcc = defineModel('bcc', { default: '' })
|
||||
const showBcc = defineModel('showBcc', { default: false })
|
||||
const emailErrors = defineModel('emailErrors', { default: () => [] })
|
||||
const htmlContent = defineModel('htmlContent', { default: '' })
|
||||
const textContent = defineModel('textContent', { default: '' })
|
||||
const selectedText = defineModel('selectedText', { default: '' })
|
||||
const isBold = defineModel('isBold', { default: false })
|
||||
const isItalic = defineModel('isItalic', { default: false })
|
||||
const cursorPosition = defineModel('cursorPosition', { default: 0 })
|
||||
|
||||
const props = defineProps({
|
||||
isFullscreen: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
aiPrompts: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
isSending: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
uploadingFiles: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
clearEditorContent: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
contentToSet: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'toggleFullscreen',
|
||||
'send',
|
||||
'fileUpload',
|
||||
'inlineImageUpload',
|
||||
'fileDelete',
|
||||
'aiPromptSelected'
|
||||
])
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
const emitter = useEmitter()
|
||||
|
||||
const insertContent = ref(null)
|
||||
const setInlineImage = ref(null)
|
||||
const editorPlaceholder = 'Press Enter to add a new line; Press Ctrl + Enter to send.'
|
||||
|
||||
const toggleBcc = async () => {
|
||||
showBcc.value = !showBcc.value
|
||||
await nextTick()
|
||||
// If hiding BCC field, clear the content
|
||||
if (!showBcc.value) {
|
||||
bcc.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
emit('toggleFullscreen')
|
||||
}
|
||||
|
||||
const toggleBold = () => {
|
||||
isBold.value = !isBold.value
|
||||
}
|
||||
|
||||
const toggleItalic = () => {
|
||||
isItalic.value = !isItalic.value
|
||||
}
|
||||
|
||||
const attachments = computed(() => {
|
||||
return conversationStore.conversation.mediaFiles.filter(
|
||||
(upload) => upload.disposition === 'attachment'
|
||||
)
|
||||
})
|
||||
|
||||
const enableSend = computed(() => {
|
||||
return (
|
||||
(textContent.value.trim().length > 0 ||
|
||||
conversationStore.conversation?.macro?.actions?.length > 0) &&
|
||||
emailErrors.value.length === 0 &&
|
||||
!props.uploadingFiles.length
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Validate email addresses in the CC and BCC fields
|
||||
* @param {string} field - 'cc' or 'bcc'
|
||||
*/
|
||||
const validateEmails = (field) => {
|
||||
const emails = field === 'cc' ? cc.value : bcc.value
|
||||
const emailList = emails
|
||||
.split(',')
|
||||
.map((e) => e.trim())
|
||||
.filter((e) => e !== '')
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
const invalidEmails = emailList.filter((email) => !emailRegex.test(email))
|
||||
|
||||
// Remove any existing errors for this field
|
||||
emailErrors.value = emailErrors.value.filter(
|
||||
(error) => !error.startsWith(`Invalid email(s) in ${field.toUpperCase()}`)
|
||||
)
|
||||
|
||||
// Add new error if there are invalid emails
|
||||
if (invalidEmails.length > 0) {
|
||||
emailErrors.value.push(
|
||||
`Invalid email(s) in ${field.toUpperCase()}: ${invalidEmails.join(', ')}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the reply or private note
|
||||
*/
|
||||
const handleSend = async () => {
|
||||
validateEmails('cc')
|
||||
validateEmails('bcc')
|
||||
if (emailErrors.value.length > 0) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Error',
|
||||
variant: 'destructive',
|
||||
description: 'Please correct the email errors before sending.'
|
||||
})
|
||||
return
|
||||
}
|
||||
emit('send')
|
||||
}
|
||||
|
||||
const handleFileUpload = (event) => {
|
||||
emit('fileUpload', event)
|
||||
}
|
||||
|
||||
const handleInlineImageUpload = (event) => {
|
||||
emit('inlineImageUpload', event)
|
||||
}
|
||||
|
||||
const handleOnFileDelete = (uuid) => {
|
||||
emit('fileDelete', uuid)
|
||||
}
|
||||
|
||||
const handleEmojiSelect = (emoji) => {
|
||||
insertContent.value = undefined
|
||||
// Force reactivity so the user can select the same emoji multiple times
|
||||
nextTick(() => (insertContent.value = emoji))
|
||||
}
|
||||
|
||||
const handleAiPromptSelected = (key) => {
|
||||
emit('aiPromptSelected', key)
|
||||
}
|
||||
</script>
|
||||
@@ -35,7 +35,9 @@
|
||||
<Smile class="h-4 w-4" />
|
||||
</Toggle>
|
||||
</div>
|
||||
<Button class="h-8 w-6 px-8" @click="handleSend" :disabled="!enableSend" :isLoading="isSending">Send</Button>
|
||||
<Button class="h-8 w-6 px-8" @click="handleSend" :disabled="!enableSend" :isLoading="isSending"
|
||||
>Send</Button
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -52,11 +54,10 @@ const attachmentInput = ref(null)
|
||||
const inlineImageInput = ref(null)
|
||||
const isEmojiPickerVisible = ref(false)
|
||||
const emojiPickerRef = ref(null)
|
||||
const emit = defineEmits(['toggleBold', 'toggleItalic', 'emojiSelect'])
|
||||
const emit = defineEmits(['emojiSelect'])
|
||||
|
||||
// Using defineProps for props that don't need two-way binding
|
||||
defineProps({
|
||||
isBold: Boolean,
|
||||
isItalic: Boolean,
|
||||
isSending: Boolean,
|
||||
enableSend: Boolean,
|
||||
handleSend: Function,
|
||||
@@ -69,7 +70,11 @@ onClickOutside(emojiPickerRef, () => {
|
||||
})
|
||||
|
||||
const triggerFileUpload = () => {
|
||||
attachmentInput.value.click()
|
||||
if (attachmentInput.value) {
|
||||
// Clear the value to allow the same file to be uploaded again.
|
||||
attachmentInput.value.value = ''
|
||||
attachmentInput.value.click()
|
||||
}
|
||||
}
|
||||
|
||||
const toggleEmojiPicker = () => {
|
||||
|
||||
@@ -19,7 +19,11 @@
|
||||
}"
|
||||
>
|
||||
<!-- Message Content -->
|
||||
<div v-html="messageContent" :class="{ 'mb-3': message.attachments.length > 0 }"></div>
|
||||
<div
|
||||
v-dompurify-html="messageContent"
|
||||
class="whitespace-pre-wrap break-words overflow-wrap-anywhere"
|
||||
:class="{ 'mb-3': message.attachments.length > 0 }"
|
||||
/>
|
||||
|
||||
<!-- Attachments -->
|
||||
<MessageAttachmentPreview :attachments="nonInlineAttachments" />
|
||||
@@ -125,3 +129,9 @@ const retryMessage = (msg) => {
|
||||
api.retryMessage(convStore.current.uuid, msg.uuid)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.overflow-wrap-anywhere {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 translate-y-1"
|
||||
>
|
||||
<div v-show="!isAtBottom" class="absolute bottom-12 right-6 z-10">
|
||||
<div v-show="!isAtBottom" class="absolute bottom-5 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"
|
||||
|
||||
@@ -1,25 +1,36 @@
|
||||
<template>
|
||||
<div class="flex flex-1 flex-col gap-x-5 box p-5 space-y-5 bg-white">
|
||||
<div class="flex items-center space-x-2">
|
||||
<p class="text-2xl">{{ title }}</p>
|
||||
<p class="text-2xl flex items-center">{{ title }}</p>
|
||||
<div class="bg-green-100/70 flex items-center space-x-2 px-1 rounded">
|
||||
<span class="blinking-dot"></span>
|
||||
<p class="uppercase text-xs">Live</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between pr-32">
|
||||
<div v-for="(value, key) in counts" :key="key" class="flex flex-col items-center gap-y-2">
|
||||
<div
|
||||
v-for="(item, key) in filteredCounts"
|
||||
:key="key"
|
||||
class="flex flex-col items-center gap-y-2"
|
||||
>
|
||||
<span class="text-muted-foreground">{{ labels[key] }}</span>
|
||||
<span class="text-2xl font-medium">{{ value }}</span>
|
||||
<span class="text-2xl font-medium">{{ item }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
counts: { type: Object, required: true },
|
||||
labels: { type: Object, required: true },
|
||||
title: { type: String, required: true }
|
||||
})
|
||||
|
||||
// Filter out counts that don't have a label
|
||||
const filteredCounts = computed(() => {
|
||||
return Object.fromEntries(Object.entries(props.counts).filter(([key]) => props.labels[key]))
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -7,6 +7,7 @@ import mitt from 'mitt'
|
||||
import api from './api'
|
||||
import './assets/styles/main.scss'
|
||||
import './utils/strings.js'
|
||||
import VueDOMPurifyHTML from 'vue-dompurify-html'
|
||||
import Root from './Root.vue'
|
||||
|
||||
const setFavicon = (url) => {
|
||||
@@ -50,6 +51,7 @@ async function initApp () {
|
||||
|
||||
app.use(router)
|
||||
app.use(i18n)
|
||||
app.use(VueDOMPurifyHTML)
|
||||
app.mount('#app')
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,6 @@ const routes = [
|
||||
path: '',
|
||||
name: 'team-inbox',
|
||||
component: InboxView,
|
||||
props: true,
|
||||
meta: { title: 'Team inbox' }
|
||||
},
|
||||
{
|
||||
@@ -88,7 +87,6 @@ const routes = [
|
||||
path: '',
|
||||
name: 'view-inbox',
|
||||
component: InboxView,
|
||||
props: true,
|
||||
meta: { title: 'View inbox' }
|
||||
},
|
||||
{
|
||||
@@ -118,7 +116,6 @@ const routes = [
|
||||
path: '',
|
||||
name: 'inbox',
|
||||
component: InboxView,
|
||||
props: true,
|
||||
meta: {
|
||||
title: 'Inbox',
|
||||
type: route => route.params.type === 'assigned' ? 'My inbox' : route.params.type
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { computed, reactive, ref, nextTick } from 'vue'
|
||||
import { CONVERSATION_LIST_TYPE, CONVERSATION_DEFAULT_STATUSES } from '@/constants/conversation'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
@@ -110,8 +110,11 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
clearInterval(reRenderInterval)
|
||||
}
|
||||
|
||||
function setMacro (macros) {
|
||||
conversation.macro = macros
|
||||
async function setMacro (macro) {
|
||||
// Clear existing macro.
|
||||
conversation.macro = {}
|
||||
await nextTick()
|
||||
conversation.macro = macro
|
||||
}
|
||||
|
||||
function removeMacroAction (action) {
|
||||
@@ -231,6 +234,10 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
return conversation.data || {}
|
||||
})
|
||||
|
||||
const hasConversationOpen = computed(() => {
|
||||
return Object.keys(conversation.data || {}).length > 0
|
||||
})
|
||||
|
||||
const currentBCC = computed(() => {
|
||||
return conversation.data?.bcc || []
|
||||
})
|
||||
@@ -282,8 +289,10 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
async function fetchMessages (uuid, fetchNextPage = false) {
|
||||
// Messages are already cached?
|
||||
let hasMessages = messages.data.getAllPagesMessages(uuid)
|
||||
if (hasMessages.length > 0 && !fetchNextPage)
|
||||
if (hasMessages.length > 0 && !fetchNextPage) {
|
||||
markConversationAsRead(uuid)
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch messages from server.
|
||||
messages.loading = true
|
||||
@@ -293,7 +302,6 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
const response = await api.getConversationMessages(uuid, { page: page, page_size: MESSAGE_LIST_PAGE_SIZE })
|
||||
const result = response.data?.data || {}
|
||||
const newMessages = result.results || []
|
||||
// Mark conversation as read
|
||||
markConversationAsRead(uuid)
|
||||
// Cache messages
|
||||
messages.data.addMessages(uuid, newMessages, result.page, result.total_pages)
|
||||
@@ -608,8 +616,8 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
Object.assign(conversation, {
|
||||
data: null,
|
||||
participants: {},
|
||||
macro: {},
|
||||
mediaFiles: [],
|
||||
macro: {},
|
||||
loading: false,
|
||||
errorMessage: ''
|
||||
})
|
||||
@@ -629,6 +637,7 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
conversationsList,
|
||||
conversationMessages,
|
||||
currentConversationHasMoreMessages,
|
||||
hasConversationOpen,
|
||||
current,
|
||||
currentContactName,
|
||||
currentBCC,
|
||||
|
||||
@@ -15,14 +15,15 @@ export const useUserStore = defineStore('user', () => {
|
||||
avatar_url: '',
|
||||
email: '',
|
||||
teams: [],
|
||||
permissions: []
|
||||
permissions: [],
|
||||
availability_status: 'offline'
|
||||
})
|
||||
const emitter = useEmitter()
|
||||
|
||||
const userID = computed(() => user.value.id)
|
||||
const firstName = computed(() => user.value.first_name)
|
||||
const lastName = computed(() => user.value.last_name)
|
||||
const avatar = computed(() => user.value.avatar_url)
|
||||
const firstName = computed(() => user.value.first_name || '')
|
||||
const lastName = computed(() => user.value.last_name || '')
|
||||
const avatar = computed(() => user.value.avatar_url || '')
|
||||
const permissions = computed(() => user.value.permissions || [])
|
||||
const email = computed(() => user.value.email)
|
||||
const teams = computed(() => user.value.teams || [])
|
||||
@@ -71,6 +72,10 @@ export const useUserStore = defineStore('user', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const setCurrentUser = (userData) => {
|
||||
user.value = userData
|
||||
}
|
||||
|
||||
const setAvatar = (avatarURL) => {
|
||||
if (typeof avatarURL !== 'string') {
|
||||
console.warn('Avatar URL must be a string')
|
||||
@@ -83,6 +88,16 @@ export const useUserStore = defineStore('user', () => {
|
||||
user.value.avatar_url = ''
|
||||
}
|
||||
|
||||
const updateUserAvailability = async (status, isManual = true) => {
|
||||
try {
|
||||
const apiStatus = status === 'away' && isManual ? 'away_manual' : status
|
||||
await api.updateCurrentUserAvailability({ status: apiStatus })
|
||||
user.value.availability_status = apiStatus
|
||||
} catch (error) {
|
||||
if (error?.response?.status === 401) window.location.href = '/'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
userID,
|
||||
@@ -96,9 +111,11 @@ export const useUserStore = defineStore('user', () => {
|
||||
getInitials,
|
||||
hasAdminTabPermissions,
|
||||
hasReportTabPermissions,
|
||||
setCurrentUser,
|
||||
getCurrentUser,
|
||||
clearAvatar,
|
||||
setAvatar,
|
||||
updateUserAvailability,
|
||||
can
|
||||
}
|
||||
})
|
||||
})
|
||||
7
frontend/src/utils/debounce.js
Normal file
7
frontend/src/utils/debounce.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export function debounce (fn, delay) {
|
||||
let timeout
|
||||
return function (...args) {
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(() => fn(...args), delay)
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { watch, onMounted } from 'vue'
|
||||
import { watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import Conversation from '@/features/conversation/Conversation.vue'
|
||||
import ConversationSideBarWrapper from '@/features/conversation/sidebar/ConversationSideBarWrapper.vue'
|
||||
@@ -37,6 +37,10 @@ onMounted(() => {
|
||||
if (props.uuid) fetchConversation(props.uuid)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
conversationStore.resetCurrentConversation()
|
||||
})
|
||||
|
||||
// Watcher for UUID changes
|
||||
watch(
|
||||
() => props.uuid,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<ConversationPlaceholder v-if="route.name === 'inbox'" />
|
||||
<ConversationPlaceholder v-if="['inbox', 'team-inbox', 'view-inbox'].includes(route.name)" />
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
|
||||
@@ -138,12 +138,14 @@ import { Card, CardContent, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
|
||||
const emitter = useEmitter()
|
||||
const errorMessage = ref('')
|
||||
const isLoading = ref(false)
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const loginForm = ref({
|
||||
email: '',
|
||||
password: ''
|
||||
@@ -207,7 +209,10 @@ const loginAction = () => {
|
||||
email: loginForm.value.email,
|
||||
password: loginForm.value.password
|
||||
})
|
||||
.then(() => {
|
||||
.then((resp) => {
|
||||
if (resp?.data?.data) {
|
||||
userStore.setCurrentUser(resp.data.data)
|
||||
}
|
||||
router.push({ name: 'inboxes' })
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
@@ -76,8 +76,7 @@
|
||||
</main>
|
||||
|
||||
<footer class="p-6 text-center">
|
||||
<div class="text-sm text-muted-foreground space-x-4">
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground space-x-4"></div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
@@ -93,10 +92,13 @@ import { Button } from '@/components/ui/button'
|
||||
import { Error } from '@/components/ui/error'
|
||||
import { Card, CardContent, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
const errorMessage = ref('')
|
||||
const isLoading = ref(false)
|
||||
const emitter = useEmitter()
|
||||
const router = useRouter()
|
||||
const resetForm = ref({
|
||||
email: ''
|
||||
@@ -121,16 +123,16 @@ const requestResetAction = async () => {
|
||||
await api.resetPassword({
|
||||
email: resetForm.value.email
|
||||
})
|
||||
toast({
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Reset link sent',
|
||||
description: 'Please check your email for the reset link.'
|
||||
})
|
||||
router.push({ name: 'login' })
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: err.response.data.message,
|
||||
variant: 'destructive'
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Reset link sent',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(err).message
|
||||
})
|
||||
errorMessage.value = handleHTTPError(err).message
|
||||
useTemporaryClass('reset-password-container', 'animate-shake')
|
||||
|
||||
@@ -125,18 +125,16 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
const validateForm = () => {
|
||||
if (!passwordForm.value.password || passwordForm.value.password.length < 8) {
|
||||
errorMessage.value = 'Password must be at least 8 characters long.'
|
||||
if (!passwordForm.value.password) {
|
||||
errorMessage.value = 'Password is required.'
|
||||
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
|
||||
}
|
||||
|
||||
@@ -156,11 +154,6 @@ const setPasswordAction = async () => {
|
||||
})
|
||||
router.push({ name: 'login' })
|
||||
} catch (err) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Error',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(err).message
|
||||
})
|
||||
errorMessage.value = handleHTTPError(err).message
|
||||
useTemporaryClass('set-password-container', 'animate-shake')
|
||||
} finally {
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
<template>
|
||||
<div
|
||||
class="overflow-y-auto p-4 pr-36"
|
||||
:class="{ 'opacity-50 transition-opacity duration-300': isLoading }"
|
||||
>
|
||||
<Spinner v-if="isLoading" />
|
||||
<div class="space-y-4">
|
||||
<div class="text-sm text-gray-500 text-right">
|
||||
Last updated: {{ new Date(lastUpdate).toLocaleTimeString() }}
|
||||
</div>
|
||||
<div class="mt-7 flex w-full space-x-4">
|
||||
<Card title="Open conversations" :counts="cardCounts" :labels="agentCountCardsLabels" />
|
||||
<Card
|
||||
class="w-8/12"
|
||||
title="Agent status"
|
||||
:counts="sampleAgentStatusCounts"
|
||||
:labels="sampleAgentStatusLabels"
|
||||
/>
|
||||
</div>
|
||||
<div class="rounded-lg box w-full p-5 bg-white">
|
||||
<LineChart :data="chartData.processedData"></LineChart>
|
||||
</div>
|
||||
<div class="rounded-lg box w-full p-5 bg-white">
|
||||
<BarChart :data="chartData.status_summary"></BarChart>
|
||||
<div class="overflow-y-auto">
|
||||
<div
|
||||
class="p-4 w-[calc(100%-3rem)]"
|
||||
:class="{ 'opacity-50 transition-opacity duration-300': isLoading }"
|
||||
>
|
||||
<Spinner v-if="isLoading" />
|
||||
<div class="space-y-4">
|
||||
<div class="text-sm text-gray-500 text-right">
|
||||
Last updated: {{ new Date(lastUpdate).toLocaleTimeString() }}
|
||||
</div>
|
||||
<div class="mt-7 flex w-full space-x-4">
|
||||
<Card title="Open conversations" :counts="cardCounts" :labels="agentCountCardsLabels" />
|
||||
<Card
|
||||
class="w-8/12"
|
||||
title="Agent status"
|
||||
:counts="agentStatusCounts"
|
||||
:labels="agentStatusLabels"
|
||||
/>
|
||||
</div>
|
||||
<div class="rounded-lg box w-full p-5 bg-white">
|
||||
<LineChart :data="chartData.processedData"></LineChart>
|
||||
</div>
|
||||
<div class="rounded-lg box w-full p-5 bg-white">
|
||||
<BarChart :data="chartData.status_summary"></BarChart>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,18 +54,18 @@ const agentCountCardsLabels = {
|
||||
pending: 'Pending'
|
||||
}
|
||||
|
||||
// TODO: Build agent status feature.
|
||||
const sampleAgentStatusLabels = {
|
||||
online: 'Online',
|
||||
offline: 'Offline',
|
||||
away: 'Away'
|
||||
}
|
||||
const sampleAgentStatusCounts = {
|
||||
online: 5,
|
||||
offline: 2,
|
||||
away: 1
|
||||
const agentStatusLabels = {
|
||||
agents_online: 'Online',
|
||||
agents_offline: 'Offline',
|
||||
agents_away: 'Away'
|
||||
}
|
||||
|
||||
const agentStatusCounts = ref({
|
||||
agents_online: 0,
|
||||
agents_offline: 0,
|
||||
agents_away: 0
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
getDashboardData()
|
||||
startRealtimeUpdates()
|
||||
@@ -96,6 +98,11 @@ const getCardStats = async () => {
|
||||
.getOverviewCounts()
|
||||
.then((resp) => {
|
||||
cardCounts.value = resp.data.data
|
||||
agentStatusCounts.value = {
|
||||
agents_online: cardCounts.value.agents_online,
|
||||
agents_offline: cardCounts.value.agents_offline,
|
||||
agents_away: cardCounts.value.agents_away
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const animate = require("tailwindcss-animate")
|
||||
const typography = require("@tailwindcss/typography")
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
@@ -140,5 +141,5 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [animate],
|
||||
}
|
||||
plugins: [animate, typography],
|
||||
}
|
||||
|
||||
@@ -234,7 +234,10 @@ SELECT json_build_object(
|
||||
'open', COUNT(*),
|
||||
'awaiting_response', COUNT(CASE WHEN c.waiting_since IS NOT NULL THEN 1 END),
|
||||
'unassigned', COUNT(CASE WHEN c.assigned_user_id IS NULL THEN 1 END),
|
||||
'pending', COUNT(CASE WHEN c.first_reply_at IS NOT NULL THEN 1 END)
|
||||
'pending', COUNT(CASE WHEN c.first_reply_at IS NOT NULL THEN 1 END),
|
||||
'agents_online', (SELECT COUNT(*) FROM users WHERE availability_status = 'online' AND type = 'agent' AND deleted_at is null),
|
||||
'agents_away', (SELECT COUNT(*) FROM users WHERE availability_status in ('away', 'away_manual') AND type = 'agent' AND deleted_at is null),
|
||||
'agents_offline', (SELECT COUNT(*) FROM users WHERE availability_status = 'offline' AND type = 'agent' AND deleted_at is null)
|
||||
)
|
||||
FROM conversations c
|
||||
INNER JOIN conversation_statuses s ON c.status_id = s.id
|
||||
|
||||
22
internal/migrations/v0.3.0.go
Normal file
22
internal/migrations/v0.3.0.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/knadh/stuffbin"
|
||||
)
|
||||
|
||||
// V0_3_0 updates the database schema to v0.3.0.
|
||||
func V0_3_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
|
||||
_, err := db.Exec(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_availability_status') THEN
|
||||
CREATE TYPE user_availability_status AS ENUM ('online', 'away', 'away_manual', 'offline');
|
||||
END IF;
|
||||
END$$;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS availability_status user_availability_status DEFAULT 'offline' NOT NULL;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_active_at TIMESTAMPTZ NULL;
|
||||
`)
|
||||
return err
|
||||
}
|
||||
@@ -8,29 +8,37 @@ import (
|
||||
"github.com/volatiletech/null/v9"
|
||||
)
|
||||
|
||||
var (
|
||||
Online = "online"
|
||||
Offline = "offline"
|
||||
Away = "away"
|
||||
AwayManual = "away_manual"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID int `db:"id" json:"id,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
FirstName string `db:"first_name" json:"first_name"`
|
||||
LastName string `db:"last_name" json:"last_name"`
|
||||
Email null.String `db:"email" json:"email,omitempty"`
|
||||
Type string `db:"type" json:"type"`
|
||||
PhoneNumber null.String `db:"phone_number" json:"phone_number,omitempty"`
|
||||
AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
|
||||
Enabled bool `db:"enabled" json:"enabled"`
|
||||
Password string `db:"password" json:"-"`
|
||||
Roles pq.StringArray `db:"roles" json:"roles,omitempty"`
|
||||
Permissions pq.StringArray `db:"permissions" json:"permissions,omitempty"`
|
||||
Meta pq.StringArray `db:"meta" json:"meta,omitempty"`
|
||||
CustomAttributes pq.StringArray `db:"custom_attributes" json:"custom_attributes,omitempty"`
|
||||
Teams tmodels.Teams `db:"teams" json:"teams,omitempty"`
|
||||
ContactChannelID int `db:"contact_channel_id" json:"contact_channel_id,omitempty"`
|
||||
NewPassword string `db:"-" json:"new_password,omitempty"`
|
||||
SendWelcomeEmail bool `db:"-" json:"send_welcome_email,omitempty"`
|
||||
InboxID int `json:"-"`
|
||||
SourceChannel null.String `json:"-"`
|
||||
SourceChannelID null.String `json:"-"`
|
||||
ID int `db:"id" json:"id,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
FirstName string `db:"first_name" json:"first_name"`
|
||||
LastName string `db:"last_name" json:"last_name"`
|
||||
Email null.String `db:"email" json:"email,omitempty"`
|
||||
Type string `db:"type" json:"type"`
|
||||
AvailabilityStatus string `db:"availability_status" json:"availability_status"`
|
||||
PhoneNumber null.String `db:"phone_number" json:"phone_number,omitempty"`
|
||||
AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
|
||||
Enabled bool `db:"enabled" json:"enabled"`
|
||||
Password string `db:"password" json:"-"`
|
||||
Roles pq.StringArray `db:"roles" json:"roles,omitempty"`
|
||||
Permissions pq.StringArray `db:"permissions" json:"permissions,omitempty"`
|
||||
Meta pq.StringArray `db:"meta" json:"meta,omitempty"`
|
||||
CustomAttributes pq.StringArray `db:"custom_attributes" json:"custom_attributes,omitempty"`
|
||||
Teams tmodels.Teams `db:"teams" json:"teams,omitempty"`
|
||||
ContactChannelID int `db:"contact_channel_id" json:"contact_channel_id,omitempty"`
|
||||
NewPassword string `db:"-" json:"new_password,omitempty"`
|
||||
SendWelcomeEmail bool `db:"-" json:"send_welcome_email,omitempty"`
|
||||
InboxID int `json:"-"`
|
||||
SourceChannel null.String `json:"-"`
|
||||
SourceChannelID null.String `json:"-"`
|
||||
}
|
||||
|
||||
func (u *User) FullName() string {
|
||||
|
||||
@@ -20,41 +20,32 @@ SELECT email
|
||||
FROM users
|
||||
WHERE id = $1 AND deleted_at IS NULL AND type = 'agent';
|
||||
|
||||
-- name: get-user-by-email
|
||||
SELECT u.id, u.email, u.password, u.avatar_url, u.first_name, u.last_name, u.enabled,
|
||||
array_agg(DISTINCT r.name) as roles,
|
||||
array_agg(DISTINCT p) as permissions
|
||||
FROM users u
|
||||
JOIN user_roles ur ON ur.user_id = u.id
|
||||
JOIN roles r ON r.id = ur.role_id,
|
||||
unnest(r.permissions) p
|
||||
WHERE u.email = $1 AND u.deleted_at IS NULL AND u.type = 'agent'
|
||||
GROUP BY u.id;
|
||||
|
||||
-- name: get-user
|
||||
SELECT
|
||||
u.id,
|
||||
u.created_at,
|
||||
u.updated_at,
|
||||
u.enabled,
|
||||
u.email,
|
||||
u.avatar_url,
|
||||
u.first_name,
|
||||
u.last_name,
|
||||
array_agg(DISTINCT r.name) as roles,
|
||||
COALESCE(
|
||||
(SELECT json_agg(json_build_object('id', t.id, 'name', t.name, 'emoji', t.emoji))
|
||||
FROM team_members tm
|
||||
JOIN teams t ON tm.team_id = t.id
|
||||
WHERE tm.user_id = u.id),
|
||||
'[]'
|
||||
) AS teams,
|
||||
array_agg(DISTINCT p) as permissions
|
||||
u.id,
|
||||
u.email,
|
||||
u.password,
|
||||
u.created_at,
|
||||
u.updated_at,
|
||||
u.enabled,
|
||||
u.avatar_url,
|
||||
u.first_name,
|
||||
u.last_name,
|
||||
u.availability_status,
|
||||
array_agg(DISTINCT r.name) as roles,
|
||||
COALESCE(
|
||||
(SELECT json_agg(json_build_object('id', t.id, 'name', t.name, 'emoji', t.emoji))
|
||||
FROM team_members tm
|
||||
JOIN teams t ON tm.team_id = t.id
|
||||
WHERE tm.user_id = u.id),
|
||||
'[]'
|
||||
) AS teams,
|
||||
array_agg(DISTINCT p) as permissions
|
||||
FROM users u
|
||||
LEFT JOIN user_roles ur ON ur.user_id = u.id
|
||||
LEFT JOIN roles r ON r.id = ur.role_id,
|
||||
unnest(r.permissions) p
|
||||
WHERE u.id = $1 AND u.deleted_at IS NULL AND u.type = 'agent'
|
||||
unnest(r.permissions) p
|
||||
WHERE (u.id = $1 OR u.email = $2) AND u.deleted_at IS NULL AND u.type = 'agent'
|
||||
GROUP BY u.id;
|
||||
|
||||
-- name: set-user-password
|
||||
@@ -92,6 +83,22 @@ UPDATE users
|
||||
SET avatar_url = $2, updated_at = now()
|
||||
WHERE id = $1 AND type = 'agent';
|
||||
|
||||
-- name: update-availability
|
||||
UPDATE users
|
||||
SET availability_status = $2
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: update-last-active-at
|
||||
UPDATE users
|
||||
SET last_active_at = now(),
|
||||
availability_status = CASE WHEN availability_status = 'offline' THEN 'online' ELSE availability_status END
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: update-inactive-offline
|
||||
UPDATE users
|
||||
SET availability_status = 'offline'
|
||||
WHERE last_active_at < now() - interval '5 minutes' and availability_status != 'offline';
|
||||
|
||||
-- name: get-permissions
|
||||
SELECT DISTINCT unnest(r.permissions)
|
||||
FROM users u
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"log"
|
||||
|
||||
@@ -61,13 +62,15 @@ type Opts struct {
|
||||
// queries contains prepared SQL queries.
|
||||
type queries struct {
|
||||
GetUsers *sqlx.Stmt `query:"get-users"`
|
||||
GetUserCompact *sqlx.Stmt `query:"get-users-compact"`
|
||||
GetUsersCompact *sqlx.Stmt `query:"get-users-compact"`
|
||||
GetUser *sqlx.Stmt `query:"get-user"`
|
||||
GetEmail *sqlx.Stmt `query:"get-email"`
|
||||
GetPermissions *sqlx.Stmt `query:"get-permissions"`
|
||||
GetUserByEmail *sqlx.Stmt `query:"get-user-by-email"`
|
||||
UpdateUser *sqlx.Stmt `query:"update-user"`
|
||||
UpdateAvatar *sqlx.Stmt `query:"update-avatar"`
|
||||
UpdateAvailability *sqlx.Stmt `query:"update-availability"`
|
||||
UpdateLastActiveAt *sqlx.Stmt `query:"update-last-active-at"`
|
||||
UpdateInactiveOffline *sqlx.Stmt `query:"update-inactive-offline"`
|
||||
SoftDeleteUser *sqlx.Stmt `query:"soft-delete-user"`
|
||||
SetUserPassword *sqlx.Stmt `query:"set-user-password"`
|
||||
SetResetPasswordToken *sqlx.Stmt `query:"set-reset-password-token"`
|
||||
@@ -89,22 +92,19 @@ func New(i18n *i18n.I18n, opts Opts) (*Manager, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// VerifyPassword authenticates a user by email and password.
|
||||
// VerifyPassword authenticates an user by email and password.
|
||||
func (u *Manager) VerifyPassword(email string, password []byte) (models.User, error) {
|
||||
var user models.User
|
||||
|
||||
if err := u.q.GetUserByEmail.Get(&user, email); err != nil {
|
||||
if err := u.q.GetUser.Get(&user, 0, email); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return user, envelope.NewError(envelope.InputError, u.i18n.T("user.invalidEmailPassword"), nil)
|
||||
}
|
||||
u.lo.Error("error fetching user from db", "error", err)
|
||||
return user, envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorFetching", "name", "{globals.entities.user}"), nil)
|
||||
}
|
||||
|
||||
if err := u.verifyPassword(password, user.Password); err != nil {
|
||||
return user, envelope.NewError(envelope.InputError, u.i18n.T("user.invalidEmailPassword"), nil)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ func (u *Manager) GetAll() ([]models.User, error) {
|
||||
// GetAllCompact returns a compact list of users with limited fields.
|
||||
func (u *Manager) GetAllCompact() ([]models.User, error) {
|
||||
var users = make([]models.User, 0)
|
||||
if err := u.q.GetUserCompact.Select(&users); err != nil {
|
||||
if err := u.q.GetUsersCompact.Select(&users); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return users, nil
|
||||
}
|
||||
@@ -154,10 +154,10 @@ func (u *Manager) CreateAgent(user *models.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get retrieves a user by ID.
|
||||
// Get retrieves an user by ID.
|
||||
func (u *Manager) Get(id int) (models.User, error) {
|
||||
var user models.User
|
||||
if err := u.q.GetUser.Get(&user, id); err != nil {
|
||||
if err := u.q.GetUser.Get(&user, id, ""); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
u.lo.Error("user not found", "id", id, "error", err)
|
||||
return user, envelope.NewError(envelope.GeneralError, "User not found", nil)
|
||||
@@ -168,10 +168,10 @@ func (u *Manager) Get(id int) (models.User, error) {
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// GetByEmail retrieves a user by email
|
||||
// GetByEmail retrieves an user by email
|
||||
func (u *Manager) GetByEmail(email string) (models.User, error) {
|
||||
var user models.User
|
||||
if err := u.q.GetUserByEmail.Get(&user, email); err != nil {
|
||||
if err := u.q.GetUser.Get(&user, 0, email); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return user, envelope.NewError(envelope.GeneralError, "User not found", nil)
|
||||
}
|
||||
@@ -195,10 +195,10 @@ func (u *Manager) UpdateAvatar(id int, avatar string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update updates a user.
|
||||
// Update updates an user.
|
||||
func (u *Manager) Update(id int, user models.User) error {
|
||||
var (
|
||||
hashedPassword interface{}
|
||||
hashedPassword any
|
||||
err error
|
||||
)
|
||||
|
||||
@@ -221,7 +221,7 @@ func (u *Manager) Update(id int, user models.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SoftDelete soft deletes a user.
|
||||
// SoftDelete soft deletes an user.
|
||||
func (u *Manager) SoftDelete(id int) error {
|
||||
// Disallow if user is system user.
|
||||
systemUser, err := u.GetSystemUser()
|
||||
@@ -239,7 +239,7 @@ func (u *Manager) SoftDelete(id int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetEmail retrieves the email of a user by ID.
|
||||
// GetEmail retrieves the email of an user by ID.
|
||||
func (u *Manager) GetEmail(id int) (string, error) {
|
||||
var email string
|
||||
if err := u.q.GetEmail.Get(&email, id); err != nil {
|
||||
@@ -252,7 +252,7 @@ func (u *Manager) GetEmail(id int) (string, error) {
|
||||
return email, nil
|
||||
}
|
||||
|
||||
// SetResetPasswordToken sets a reset password token for a user and returns the token.
|
||||
// SetResetPasswordToken sets a reset password token for an user and returns the token.
|
||||
func (u *Manager) SetResetPasswordToken(id int) (string, error) {
|
||||
token, err := stringutil.RandomAlphanumeric(32)
|
||||
if err != nil {
|
||||
@@ -266,7 +266,7 @@ func (u *Manager) SetResetPasswordToken(id int) (string, error) {
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// ResetPassword sets a new password for a user.
|
||||
// ResetPassword sets a new password for an user.
|
||||
func (u *Manager) ResetPassword(token, password string) error {
|
||||
if !u.isStrongPassword(password) {
|
||||
return envelope.NewError(envelope.InputError, "Password is not strong enough, "+SystemUserPasswordHint, nil)
|
||||
@@ -277,14 +277,18 @@ func (u *Manager) ResetPassword(token, password string) error {
|
||||
u.lo.Error("error generating bcrypt password", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, "Error setting new password", nil)
|
||||
}
|
||||
if _, err := u.q.ResetPassword.Exec(passwordHash, token); err != nil {
|
||||
rows, err := u.q.ResetPassword.Exec(passwordHash, token)
|
||||
if err != nil {
|
||||
u.lo.Error("error setting new password", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, "Error setting new password", nil)
|
||||
}
|
||||
if count, _ := rows.RowsAffected(); count == 0 {
|
||||
return envelope.NewError(envelope.InputError, "Token is invalid or expired, please try again by requesting a new password reset link", nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPermissions retrieves the permissions of a user by ID.
|
||||
// GetPermissions retrieves the permissions of an user by ID.
|
||||
func (u *Manager) GetPermissions(id int) ([]string, error) {
|
||||
var permissions []string
|
||||
if err := u.q.GetPermissions.Select(&permissions, id); err != nil {
|
||||
@@ -294,6 +298,52 @@ func (u *Manager) GetPermissions(id int) ([]string, error) {
|
||||
return permissions, nil
|
||||
}
|
||||
|
||||
// UpdateAvailability updates the availability status of an user.
|
||||
func (u *Manager) UpdateAvailability(id int, status string) error {
|
||||
if _, err := u.q.UpdateAvailability.Exec(id, status); err != nil {
|
||||
u.lo.Error("error updating user availability", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, "Error updating user availability", nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateLastActive updates the last active timestamp of an user.
|
||||
func (u *Manager) UpdateLastActive(id int) error {
|
||||
if _, err := u.q.UpdateLastActiveAt.Exec(id); err != nil {
|
||||
u.lo.Error("error updating user last active at", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, "Error updating user last active at", nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MonitorAgentAvailability continuously checks for user activity and sets them offline if inactive for more than 5 minutes.
|
||||
func (u *Manager) MonitorAgentAvailability(ctx context.Context) {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
u.markInactiveAgentsOffline()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// markInactiveAgentsOffline sets agents offline if they have been inactive for more than 5 minutes.
|
||||
func (u *Manager) markInactiveAgentsOffline() {
|
||||
u.lo.Debug("marking inactive agents offline")
|
||||
if res, err := u.q.UpdateInactiveOffline.Exec(); err != nil {
|
||||
u.lo.Error("error setting users offline", "error", err)
|
||||
} else {
|
||||
rows, _ := res.RowsAffected()
|
||||
if rows > 0 {
|
||||
u.lo.Info("set inactive users offline", "count", rows)
|
||||
}
|
||||
}
|
||||
u.lo.Debug("marked inactive agents offline")
|
||||
}
|
||||
|
||||
// verifyPassword compares the provided password with the stored password hash.
|
||||
func (u *Manager) verifyPassword(pwd []byte, pwdHash string) error {
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(pwdHash), pwd); err != nil {
|
||||
|
||||
@@ -94,7 +94,9 @@ func (c *Client) Listen() {
|
||||
|
||||
// processIncomingMessage processes incoming messages from the client.
|
||||
func (c *Client) processIncomingMessage(data []byte) {
|
||||
// Handle ping messages, and update last active time for user.
|
||||
if string(data) == "ping" {
|
||||
c.Hub.userStore.UpdateLastActive(c.ID)
|
||||
c.SendMessage([]byte("pong"), websocket.TextMessage)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -13,13 +13,20 @@ type Hub struct {
|
||||
// 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
|
||||
|
||||
userStore userStore
|
||||
}
|
||||
|
||||
type userStore interface {
|
||||
UpdateLastActive(userID int) error
|
||||
}
|
||||
|
||||
// NewHub creates a new websocket hub.
|
||||
func NewHub() *Hub {
|
||||
func NewHub(userStore userStore) *Hub {
|
||||
return &Hub{
|
||||
clients: make(map[int][]*Client, 10000),
|
||||
clientsMutex: sync.Mutex{},
|
||||
userStore: userStore,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ DROP TYPE IF EXISTS "automation_execution_mode" CASCADE; CREATE TYPE "automation
|
||||
DROP TYPE IF EXISTS "macro_visibility" CASCADE; CREATE TYPE "macro_visibility" AS ENUM ('all', 'team', 'user');
|
||||
DROP TYPE IF EXISTS "media_disposition" CASCADE; CREATE TYPE "media_disposition" AS ENUM ('inline', 'attachment');
|
||||
DROP TYPE IF EXISTS "media_store" CASCADE; CREATE TYPE "media_store" AS ENUM ('s3', 'fs');
|
||||
DROP TYPE IF EXISTS "user_availability_status" CASCADE; CREATE TYPE "user_availability_status" AS ENUM ('online', 'away', 'away_manual', 'offline');
|
||||
|
||||
-- Sequence to generate reference number for conversations.
|
||||
DROP SEQUENCE IF EXISTS conversation_reference_number_sequence; CREATE SEQUENCE conversation_reference_number_sequence START 100;
|
||||
@@ -118,6 +119,8 @@ CREATE TABLE users (
|
||||
custom_attributes JSONB DEFAULT '{}'::jsonb NOT NULL,
|
||||
reset_password_token TEXT NULL,
|
||||
reset_password_token_expiry TIMESTAMPTZ NULL,
|
||||
availability_status user_availability_status DEFAULT 'offline' NOT NULL,
|
||||
last_active_at TIMESTAMPTZ NULL,
|
||||
CONSTRAINT constraint_users_on_country CHECK (LENGTH(country) <= 140),
|
||||
CONSTRAINT constraint_users_on_phone_number CHECK (LENGTH(phone_number) <= 20),
|
||||
CONSTRAINT constraint_users_on_email_length CHECK (LENGTH(email) <= 320),
|
||||
|
||||
Reference in New Issue
Block a user