mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-11-20 22:38:09 +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.GET("/api/v1/users/me", auth(handleGetCurrentUser))
|
||||||
g.PUT("/api/v1/users/me", auth(handleUpdateCurrentUser))
|
g.PUT("/api/v1/users/me", auth(handleUpdateCurrentUser))
|
||||||
g.GET("/api/v1/users/me/teams", auth(handleGetCurrentUserTeams))
|
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.DELETE("/api/v1/users/me/avatar", auth(handleDeleteAvatar))
|
||||||
g.GET("/api/v1/users/compact", auth(handleGetUsersCompact))
|
g.GET("/api/v1/users/compact", auth(handleGetUsersCompact))
|
||||||
g.GET("/api/v1/users", perm(handleGetUsers, "users:manage"))
|
g.GET("/api/v1/users", perm(handleGetUsers, "users:manage"))
|
||||||
|
|||||||
@@ -308,6 +308,11 @@ func initCSAT(db *sqlx.DB) *csat.Manager {
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initWS inits websocket hub.
|
||||||
|
func initWS(user *user.Manager) *ws.Hub {
|
||||||
|
return ws.NewHub(user)
|
||||||
|
}
|
||||||
|
|
||||||
// initTemplates inits template manager.
|
// initTemplates inits template manager.
|
||||||
func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants) *tmpl.Manager {
|
func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants) *tmpl.Manager {
|
||||||
var (
|
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.
|
// 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 {
|
if password != "" && !user.IsStrongSystemUserPassword(password) && !schemaInstalled {
|
||||||
log.Fatalf("system user password is not strong, %s", user.SystemUserPasswordHint)
|
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 (
|
import (
|
||||||
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
|
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
|
||||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||||
|
umodels "github.com/abhinavxd/libredesk/internal/user/models"
|
||||||
"github.com/valyala/fasthttp"
|
"github.com/valyala/fasthttp"
|
||||||
"github.com/zerodha/fastglue"
|
"github.com/zerodha/fastglue"
|
||||||
)
|
)
|
||||||
@@ -11,14 +12,20 @@ import (
|
|||||||
func handleLogin(r *fastglue.Request) error {
|
func handleLogin(r *fastglue.Request) error {
|
||||||
var (
|
var (
|
||||||
app = r.Context.(*App)
|
app = r.Context.(*App)
|
||||||
p = r.RequestCtx.PostArgs()
|
email = string(r.RequestCtx.PostArgs().Peek("email"))
|
||||||
email = string(p.Peek("email"))
|
password = r.RequestCtx.PostArgs().Peek("password")
|
||||||
password = p.Peek("password")
|
|
||||||
)
|
)
|
||||||
user, err := app.user.VerifyPassword(email, password)
|
user, err := app.user.VerifyPassword(email, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return sendErrorEnvelope(r, err)
|
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{
|
if err := app.auth.SaveSession(amodels.User{
|
||||||
ID: user.ID,
|
ID: user.ID,
|
||||||
Email: user.Email.String,
|
Email: user.Email.String,
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ import (
|
|||||||
"github.com/abhinavxd/libredesk/internal/team"
|
"github.com/abhinavxd/libredesk/internal/team"
|
||||||
"github.com/abhinavxd/libredesk/internal/template"
|
"github.com/abhinavxd/libredesk/internal/template"
|
||||||
"github.com/abhinavxd/libredesk/internal/user"
|
"github.com/abhinavxd/libredesk/internal/user"
|
||||||
"github.com/abhinavxd/libredesk/internal/ws"
|
|
||||||
"github.com/knadh/go-i18n"
|
"github.com/knadh/go-i18n"
|
||||||
"github.com/knadh/koanf/v2"
|
"github.com/knadh/koanf/v2"
|
||||||
"github.com/knadh/stuffbin"
|
"github.com/knadh/stuffbin"
|
||||||
@@ -162,7 +161,6 @@ func main() {
|
|||||||
messageOutgoingScanInterval = ko.MustDuration("message.message_outoing_scan_interval")
|
messageOutgoingScanInterval = ko.MustDuration("message.message_outoing_scan_interval")
|
||||||
slaEvaluationInterval = ko.MustDuration("sla.evaluation_interval")
|
slaEvaluationInterval = ko.MustDuration("sla.evaluation_interval")
|
||||||
lo = initLogger(appName)
|
lo = initLogger(appName)
|
||||||
wsHub = ws.NewHub()
|
|
||||||
rdb = initRedis()
|
rdb = initRedis()
|
||||||
constants = initConstants()
|
constants = initConstants()
|
||||||
i18n = initI18n(fs)
|
i18n = initI18n(fs)
|
||||||
@@ -177,6 +175,7 @@ func main() {
|
|||||||
team = initTeam(db)
|
team = initTeam(db)
|
||||||
businessHours = initBusinessHours(db)
|
businessHours = initBusinessHours(db)
|
||||||
user = initUser(i18n, db)
|
user = initUser(i18n, db)
|
||||||
|
wsHub = initWS(user)
|
||||||
notifier = initNotifier(user)
|
notifier = initNotifier(user)
|
||||||
automation = initAutomationEngine(db)
|
automation = initAutomationEngine(db)
|
||||||
sla = initSLA(db, team, settings, businessHours)
|
sla = initSLA(db, team, settings, businessHours)
|
||||||
@@ -193,6 +192,7 @@ func main() {
|
|||||||
go notifier.Run(ctx)
|
go notifier.Run(ctx)
|
||||||
go sla.Run(ctx, slaEvaluationInterval)
|
go sla.Run(ctx, slaEvaluationInterval)
|
||||||
go media.DeleteUnlinkedMedia(ctx)
|
go media.DeleteUnlinkedMedia(ctx)
|
||||||
|
go user.MonitorAgentAvailability(ctx)
|
||||||
|
|
||||||
var app = &App{
|
var app = &App{
|
||||||
lo: lo,
|
lo: lo,
|
||||||
|
|||||||
@@ -43,9 +43,7 @@ func tryAuth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
|
|||||||
// auth makes sure the user is logged in.
|
// auth makes sure the user is logged in.
|
||||||
func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
|
func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
|
||||||
return func(r *fastglue.Request) error {
|
return func(r *fastglue.Request) error {
|
||||||
var (
|
var app = r.Context.(*App)
|
||||||
app = r.Context.(*App)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Validate session and fetch user.
|
// Validate session and fetch user.
|
||||||
userSession, err := app.auth.ValidateSession(r)
|
userSession, err := app.auth.ValidateSession(r)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/abhinavxd/libredesk/internal/dbutil"
|
"github.com/abhinavxd/libredesk/internal/dbutil"
|
||||||
|
"github.com/abhinavxd/libredesk/internal/migrations"
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
"github.com/knadh/koanf/v2"
|
"github.com/knadh/koanf/v2"
|
||||||
"github.com/knadh/stuffbin"
|
"github.com/knadh/stuffbin"
|
||||||
@@ -28,7 +29,9 @@ type migFunc struct {
|
|||||||
// migList is the list of available migList ordered by the semver.
|
// migList is the list of available migList ordered by the semver.
|
||||||
// Each migration is a Go file in internal/migrations named after 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.
|
// 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
|
// 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.
|
// 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 (
|
const (
|
||||||
maxAvatarSizeMB = 5
|
maxAvatarSizeMB = 20
|
||||||
)
|
)
|
||||||
|
|
||||||
// handleGetUsers returns all users.
|
// handleGetUsers returns all users.
|
||||||
@@ -39,9 +39,7 @@ func handleGetUsers(r *fastglue.Request) error {
|
|||||||
|
|
||||||
// handleGetUsersCompact returns all users in a compact format.
|
// handleGetUsersCompact returns all users in a compact format.
|
||||||
func handleGetUsersCompact(r *fastglue.Request) error {
|
func handleGetUsersCompact(r *fastglue.Request) error {
|
||||||
var (
|
var app = r.Context.(*App)
|
||||||
app = r.Context.(*App)
|
|
||||||
)
|
|
||||||
agents, err := app.user.GetAllCompact()
|
agents, err := app.user.GetAllCompact()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
|
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
|
||||||
@@ -66,6 +64,19 @@ func handleGetUser(r *fastglue.Request) error {
|
|||||||
return r.SendEnvelope(user)
|
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.
|
// handleGetCurrentUserTeams returns the teams of a user.
|
||||||
func handleGetCurrentUserTeams(r *fastglue.Request) error {
|
func handleGetCurrentUserTeams(r *fastglue.Request) error {
|
||||||
var (
|
var (
|
||||||
@@ -228,7 +239,7 @@ func handleCreateUser(r *fastglue.Request) error {
|
|||||||
Provider: notifier.ProviderEmail,
|
Provider: notifier.ProviderEmail,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
app.lo.Error("error sending notification message", "error", err)
|
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.")
|
return r.SendEnvelope("User created successfully.")
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ git clone https://github.com/abhinavxd/libredesk.git
|
|||||||
|
|
||||||
### Running the Dev Environment
|
### 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.
|
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
|
## 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
|
```shell
|
||||||
# Download the compose file and the sample config file in the current directory.
|
# 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
|
## 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.
|
1. Make sure `go`, `nodejs`, and `pnpm` are installed on your system.
|
||||||
2. `git clone git@github.com:abhinavxd/libredesk.git`
|
2. `git clone git@github.com:abhinavxd/libredesk.git`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "libredesk",
|
"name": "libredesk",
|
||||||
"version": "0.0.0",
|
"version": "0.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
"@formkit/auto-animate": "^0.8.2",
|
"@formkit/auto-animate": "^0.8.2",
|
||||||
"@internationalized/date": "^3.5.5",
|
"@internationalized/date": "^3.5.5",
|
||||||
"@radix-icons/vue": "^1.0.0",
|
"@radix-icons/vue": "^1.0.0",
|
||||||
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@tanstack/vue-table": "^8.19.2",
|
"@tanstack/vue-table": "^8.19.2",
|
||||||
"@tiptap/extension-image": "^2.5.9",
|
"@tiptap/extension-image": "^2.5.9",
|
||||||
"@tiptap/extension-link": "^2.9.1",
|
"@tiptap/extension-link": "^2.9.1",
|
||||||
@@ -43,6 +44,7 @@
|
|||||||
"tailwind-merge": "^2.3.0",
|
"tailwind-merge": "^2.3.0",
|
||||||
"vee-validate": "^4.13.2",
|
"vee-validate": "^4.13.2",
|
||||||
"vue": "^3.4.37",
|
"vue": "^3.4.37",
|
||||||
|
"vue-dompurify-html": "^5.2.0",
|
||||||
"vue-i18n": "9",
|
"vue-i18n": "9",
|
||||||
"vue-letter": "^0.2.0",
|
"vue-letter": "^0.2.0",
|
||||||
"vue-picture-cropper": "^0.7.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':
|
'@radix-icons/vue':
|
||||||
specifier: ^1.0.0
|
specifier: ^1.0.0
|
||||||
version: 1.0.0(vue@3.5.13(typescript@5.7.3))
|
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':
|
'@tanstack/vue-table':
|
||||||
specifier: ^8.19.2
|
specifier: ^8.19.2
|
||||||
version: 8.20.5(vue@3.5.13(typescript@5.7.3))
|
version: 8.20.5(vue@3.5.13(typescript@5.7.3))
|
||||||
@@ -92,6 +95,9 @@ importers:
|
|||||||
vue:
|
vue:
|
||||||
specifier: ^3.4.37
|
specifier: ^3.4.37
|
||||||
version: 3.5.13(typescript@5.7.3)
|
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:
|
vue-i18n:
|
||||||
specifier: '9'
|
specifier: '9'
|
||||||
version: 9.14.2(vue@3.5.13(typescript@5.7.3))
|
version: 9.14.2(vue@3.5.13(typescript@5.7.3))
|
||||||
@@ -737,6 +743,11 @@ packages:
|
|||||||
'@swc/helpers@0.5.15':
|
'@swc/helpers@0.5.15':
|
||||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
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':
|
'@tanstack/table-core@8.20.5':
|
||||||
resolution: {integrity: sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==}
|
resolution: {integrity: sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -1076,6 +1087,9 @@ packages:
|
|||||||
'@types/topojson@3.2.6':
|
'@types/topojson@3.2.6':
|
||||||
resolution: {integrity: sha512-ppfdlxjxofWJ66XdLgIlER/85RvpGyfOf8jrWf+3kVIjEatFxEZYD/Ea83jO672Xu1HRzd/ghwlbcZIUNHTskw==}
|
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':
|
'@types/web-bluetooth@0.0.20':
|
||||||
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
|
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
|
||||||
|
|
||||||
@@ -1718,6 +1732,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
|
resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
|
||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
|
dompurify@3.2.4:
|
||||||
|
resolution: {integrity: sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==}
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2317,12 +2334,18 @@ packages:
|
|||||||
lodash-es@4.17.21:
|
lodash-es@4.17.21:
|
||||||
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
|
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:
|
lodash.clonedeep@4.5.0:
|
||||||
resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==}
|
resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==}
|
||||||
|
|
||||||
lodash.isequal@4.5.0:
|
lodash.isequal@4.5.0:
|
||||||
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
|
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
|
||||||
|
|
||||||
|
lodash.isplainobject@4.0.6:
|
||||||
|
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
|
||||||
|
|
||||||
lodash.merge@4.6.2:
|
lodash.merge@4.6.2:
|
||||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||||
|
|
||||||
@@ -2615,6 +2638,10 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
postcss: ^8.2.14
|
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:
|
postcss-selector-parser@6.1.2:
|
||||||
resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
|
resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -3191,6 +3218,11 @@ packages:
|
|||||||
'@vue/composition-api':
|
'@vue/composition-api':
|
||||||
optional: true
|
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:
|
vue-eslint-parser@9.4.3:
|
||||||
resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==}
|
resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==}
|
||||||
engines: {node: ^14.17.0 || >=16.0.0}
|
engines: {node: ^14.17.0 || >=16.0.0}
|
||||||
@@ -3802,6 +3834,14 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
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/table-core@8.20.5': {}
|
||||||
|
|
||||||
'@tanstack/virtual-core@3.11.2': {}
|
'@tanstack/virtual-core@3.11.2': {}
|
||||||
@@ -4187,6 +4227,9 @@ snapshots:
|
|||||||
'@types/topojson-simplify': 3.0.3
|
'@types/topojson-simplify': 3.0.3
|
||||||
'@types/topojson-specification': 1.0.5
|
'@types/topojson-specification': 1.0.5
|
||||||
|
|
||||||
|
'@types/trusted-types@2.0.7':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@types/web-bluetooth@0.0.20': {}
|
'@types/web-bluetooth@0.0.20': {}
|
||||||
|
|
||||||
'@types/yauzl@2.10.3':
|
'@types/yauzl@2.10.3':
|
||||||
@@ -4963,6 +5006,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
esutils: 2.0.3
|
esutils: 2.0.3
|
||||||
|
|
||||||
|
dompurify@3.2.4:
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/trusted-types': 2.0.7
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind-apply-helpers: 1.0.1
|
call-bind-apply-helpers: 1.0.1
|
||||||
@@ -5598,10 +5645,14 @@ snapshots:
|
|||||||
|
|
||||||
lodash-es@4.17.21: {}
|
lodash-es@4.17.21: {}
|
||||||
|
|
||||||
|
lodash.castarray@4.4.0: {}
|
||||||
|
|
||||||
lodash.clonedeep@4.5.0: {}
|
lodash.clonedeep@4.5.0: {}
|
||||||
|
|
||||||
lodash.isequal@4.5.0: {}
|
lodash.isequal@4.5.0: {}
|
||||||
|
|
||||||
|
lodash.isplainobject@4.0.6: {}
|
||||||
|
|
||||||
lodash.merge@4.6.2: {}
|
lodash.merge@4.6.2: {}
|
||||||
|
|
||||||
lodash.once@4.1.1: {}
|
lodash.once@4.1.1: {}
|
||||||
@@ -5873,6 +5924,11 @@ snapshots:
|
|||||||
postcss: 8.4.49
|
postcss: 8.4.49
|
||||||
postcss-selector-parser: 6.1.2
|
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:
|
postcss-selector-parser@6.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
cssesc: 3.0.0
|
cssesc: 3.0.0
|
||||||
@@ -6530,6 +6586,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
vue: 3.5.13(typescript@5.7.3)
|
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):
|
vue-eslint-parser@9.4.3(eslint@8.57.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.0(supports-color@8.1.1)
|
debug: 4.4.0(supports-color@8.1.1)
|
||||||
|
|||||||
@@ -48,8 +48,13 @@
|
|||||||
@delete-view="deleteView"
|
@delete-view="deleteView"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col h-screen">
|
<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 />
|
<PageHeader />
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
<RouterView class="flex-grow" />
|
<RouterView class="flex-grow" />
|
||||||
</div>
|
</div>
|
||||||
<ViewForm v-model:openDialog="openCreateViewForm" v-model:view="view" />
|
<ViewForm v-model:openDialog="openCreateViewForm" v-model:view="view" />
|
||||||
@@ -76,6 +81,7 @@ import { useTeamStore } from '@/stores/team'
|
|||||||
import { useSlaStore } from '@/stores/sla'
|
import { useSlaStore } from '@/stores/sla'
|
||||||
import { useMacroStore } from '@/stores/macro'
|
import { useMacroStore } from '@/stores/macro'
|
||||||
import { useTagStore } from '@/stores/tag'
|
import { useTagStore } from '@/stores/tag'
|
||||||
|
import { useIdleDetection } from '@/composables/useIdleDetection'
|
||||||
import PageHeader from './components/layout/PageHeader.vue'
|
import PageHeader from './components/layout/PageHeader.vue'
|
||||||
import ViewForm from '@/features/view/ViewForm.vue'
|
import ViewForm from '@/features/view/ViewForm.vue'
|
||||||
import AppUpdate from '@/components/update/AppUpdate.vue'
|
import AppUpdate from '@/components/update/AppUpdate.vue'
|
||||||
@@ -113,6 +119,8 @@ const view = ref({})
|
|||||||
const openCreateViewForm = ref(false)
|
const openCreateViewForm = ref(false)
|
||||||
|
|
||||||
initWS()
|
initWS()
|
||||||
|
useIdleDetection()
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initToaster()
|
initToaster()
|
||||||
listenViewRefresh()
|
listenViewRefresh()
|
||||||
@@ -121,8 +129,10 @@ onMounted(() => {
|
|||||||
|
|
||||||
// initialize data stores
|
// initialize data stores
|
||||||
const initStores = async () => {
|
const initStores = async () => {
|
||||||
|
if (!userStore.userID) {
|
||||||
|
await userStore.getCurrentUser()
|
||||||
|
}
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
userStore.getCurrentUser(),
|
|
||||||
getUserViews(),
|
getUserViews(),
|
||||||
conversationStore.fetchStatuses(),
|
conversationStore.fetchStatuses(),
|
||||||
conversationStore.fetchPriorities(),
|
conversationStore.fetchPriorities(),
|
||||||
|
|||||||
@@ -1,7 +1,27 @@
|
|||||||
<template>
|
<template>
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { onMounted } from 'vue'
|
||||||
import { RouterView } from 'vue-router'
|
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 deleteUserAvatar = () => http.delete('/api/v1/users/me/avatar')
|
||||||
const getCurrentUser = () => http.get('/api/v1/users/me')
|
const getCurrentUser = () => http.get('/api/v1/users/me')
|
||||||
const getCurrentUserTeams = () => http.get('/api/v1/users/me/teams')
|
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 getTags = () => http.get('/api/v1/tags')
|
||||||
const upsertTags = (uuid, data) => http.post(`/api/v1/conversations/${uuid}/tags`, data)
|
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)
|
const updateAssignee = (uuid, assignee_type, data) => http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}`, data)
|
||||||
@@ -323,6 +324,7 @@ export default {
|
|||||||
uploadMedia,
|
uploadMedia,
|
||||||
updateAssigneeLastSeen,
|
updateAssigneeLastSeen,
|
||||||
updateUser,
|
updateUser,
|
||||||
|
updateCurrentUserAvailability,
|
||||||
updateAutomationRule,
|
updateAutomationRule,
|
||||||
updateAutomationRuleWeights,
|
updateAutomationRuleWeights,
|
||||||
updateAutomationRulesExecutionMode,
|
updateAutomationRulesExecutionMode,
|
||||||
|
|||||||
@@ -1,82 +1,93 @@
|
|||||||
<template>
|
<template>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger as-child>
|
<DropdownMenuTrigger as-child>
|
||||||
<SidebarMenuButton size="lg"
|
<SidebarMenuButton
|
||||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground p-0">
|
size="lg"
|
||||||
<Avatar class="h-8 w-8 rounded-lg">
|
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground p-0"
|
||||||
<AvatarImage :src="userStore.avatar" alt="Abhinav" />
|
>
|
||||||
<AvatarFallback class="rounded-lg">
|
<Avatar class="h-8 w-8 rounded-lg relative overflow-visible">
|
||||||
{{ userStore.getInitials }}
|
<AvatarImage :src="userStore.avatar" alt="" class="rounded-lg"/>
|
||||||
</AvatarFallback>
|
<AvatarFallback class="rounded-lg">
|
||||||
</Avatar>
|
{{ userStore.getInitials }}
|
||||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
</AvatarFallback>
|
||||||
<span class="truncate font-semibold">{{ userStore.getFullName }}</span>
|
<div
|
||||||
<span class="truncate text-xs">{{ userStore.email }}</span>
|
class="absolute bottom-0 right-0 h-2.5 w-2.5 rounded-full border border-background"
|
||||||
</div>
|
:class="{
|
||||||
<ChevronsUpDown class="ml-auto size-4" />
|
'bg-green-500': userStore.user.availability_status === 'online',
|
||||||
</SidebarMenuButton>
|
'bg-amber-500': userStore.user.availability_status === 'away' || userStore.user.availability_status === 'away_manual',
|
||||||
</DropdownMenuTrigger>
|
'bg-gray-400': userStore.user.availability_status === 'offline'
|
||||||
<DropdownMenuContent class="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg" side="bottom"
|
}"
|
||||||
:side-offset="4">
|
></div>
|
||||||
<DropdownMenuLabel class="p-0 font-normal">
|
</Avatar>
|
||||||
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||||
<Avatar class="h-8 w-8 rounded-lg">
|
<span class="truncate font-semibold">{{ userStore.getFullName }}</span>
|
||||||
<AvatarImage :src="userStore.avatar" alt="Abhinav" />
|
<span class="truncate text-xs">{{ userStore.email }}</span>
|
||||||
<AvatarFallback class="rounded-lg">
|
</div>
|
||||||
{{ userStore.getInitials }}
|
<ChevronsUpDown class="ml-auto size-4" />
|
||||||
</AvatarFallback>
|
</SidebarMenuButton>
|
||||||
</Avatar>
|
</DropdownMenuTrigger>
|
||||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
<DropdownMenuContent
|
||||||
<span class="truncate font-semibold">{{ userStore.getFullName }}</span>
|
class="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
|
||||||
<span class="truncate text-xs">{{ userStore.email }}</span>
|
side="bottom"
|
||||||
</div>
|
:side-offset="4"
|
||||||
</div>
|
>
|
||||||
</DropdownMenuLabel>
|
<DropdownMenuLabel class="p-0 font-normal space-y-1">
|
||||||
<DropdownMenuSeparator />
|
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||||
<DropdownMenuGroup>
|
<Avatar class="h-8 w-8 rounded-lg">
|
||||||
<DropdownMenuItem>
|
<AvatarImage :src="userStore.avatar" alt="Abhinav" />
|
||||||
<router-link to="/account" class="flex items-center">
|
<AvatarFallback class="rounded-lg">
|
||||||
<CircleUserRound size="18" class="mr-2" />
|
{{ userStore.getInitials }}
|
||||||
Account
|
</AvatarFallback>
|
||||||
</router-link>
|
</Avatar>
|
||||||
</DropdownMenuItem>
|
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||||
</DropdownMenuGroup>
|
<span class="truncate font-semibold">{{ userStore.getFullName }}</span>
|
||||||
<DropdownMenuSeparator />
|
<span class="truncate text-xs">{{ userStore.email }}</span>
|
||||||
<DropdownMenuItem @click="logout">
|
</div>
|
||||||
<LogOut size="18" class="mr-2" />
|
</div>
|
||||||
Log out
|
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm justify-between">
|
||||||
</DropdownMenuItem>
|
<span class="text-muted-foreground">Away</span>
|
||||||
</DropdownMenuContent>
|
<Switch
|
||||||
</DropdownMenu>
|
: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>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuGroup,
|
DropdownMenuGroup,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import {
|
import { SidebarMenuButton } from '@/components/ui/sidebar'
|
||||||
SidebarMenuButton,
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
} from '@/components/ui/sidebar'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import {
|
import { ChevronsUpDown, CircleUserRound, LogOut } from 'lucide-vue-next'
|
||||||
Avatar,
|
|
||||||
AvatarFallback,
|
|
||||||
AvatarImage,
|
|
||||||
} from '@/components/ui/avatar'
|
|
||||||
import {
|
|
||||||
ChevronsUpDown,
|
|
||||||
CircleUserRound,
|
|
||||||
LogOut,
|
|
||||||
} from 'lucide-vue-next'
|
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
const logout = () => {
|
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
|
<CommandGroup
|
||||||
heading="Conversations"
|
heading="Conversations"
|
||||||
value="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-snooze" @select="setNestedCommand('snooze')"> Snooze </CommandItem>
|
||||||
<CommandItem value="conv-resolve" @select="resolveConversation"> Resolve </CommandItem>
|
<CommandItem value="conv-resolve" @select="resolveConversation"> Resolve </CommandItem>
|
||||||
@@ -45,7 +45,6 @@
|
|||||||
:data-index="index"
|
:data-index="index"
|
||||||
@select="handleApplyMacro(macro)"
|
@select="handleApplyMacro(macro)"
|
||||||
class="px-3 py-2 rounded-md cursor-pointer transition-all duration-200 hover:bg-primary/10 hover:text-primary"
|
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">
|
<div class="flex items-center space-x-2 justify-start">
|
||||||
<Zap :size="14" class="text-primary" />
|
<Zap :size="14" class="text-primary" />
|
||||||
@@ -59,7 +58,7 @@
|
|||||||
<div v-if="replyContent" class="space-y-1">
|
<div v-if="replyContent" class="space-y-1">
|
||||||
<p class="text-xs font-semibold text-primary">Reply Preview</p>
|
<p class="text-xs font-semibold text-primary">Reply Preview</p>
|
||||||
<div
|
<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"
|
v-html="replyContent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -219,7 +218,9 @@ watch([Meta_K, Ctrl_K], ([mac, win]) => {
|
|||||||
const highlightedMacro = ref(null)
|
const highlightedMacro = ref(null)
|
||||||
|
|
||||||
function handleApplyMacro(macro) {
|
function handleApplyMacro(macro) {
|
||||||
conversationStore.setMacro(macro)
|
// Create a deep copy.
|
||||||
|
const plainMacro = JSON.parse(JSON.stringify(macro))
|
||||||
|
conversationStore.setMacro(plainMacro)
|
||||||
handleOpenChange()
|
handleOpenChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
<div class="flex flex-col flex-grow overflow-hidden">
|
<div class="flex flex-col flex-grow overflow-hidden">
|
||||||
<MessageList class="flex-1 overflow-y-auto" />
|
<MessageList class="flex-1 overflow-y-auto" />
|
||||||
<div class="sticky bottom-0">
|
<div class="sticky bottom-0">
|
||||||
<ReplyBox class="h-max" />
|
<ReplyBox class="h-full" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="max-h-[600px] overflow-y-auto">
|
<div class="editor-wrapper h-full overflow-y-auto">
|
||||||
<BubbleMenu
|
<BubbleMenu
|
||||||
:editor="editor"
|
:editor="editor"
|
||||||
:tippy-options="{ duration: 100 }"
|
:tippy-options="{ duration: 100 }"
|
||||||
@@ -179,13 +179,20 @@ watchEffect(() => {
|
|||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.contentToSet,
|
() => props.contentToSet,
|
||||||
(newContent) => {
|
(newContentData) => {
|
||||||
if (newContent === '') {
|
if (!newContentData) return
|
||||||
editor.value?.commands.clearContent()
|
try {
|
||||||
} else {
|
const parsedData = JSON.parse(newContentData)
|
||||||
editor.value?.commands.setContent(newContent, true)
|
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;
|
height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Editor height
|
// Ensure the parent div has a proper height
|
||||||
.ProseMirror {
|
.editor-wrapper div[aria-expanded='false'] {
|
||||||
min-height: 80px !important;
|
display: flex;
|
||||||
max-height: 60% !important;
|
flex-direction: column;
|
||||||
overflow-y: scroll !important;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fullscreen-tiptap-editor {
|
// Ensure the editor content has a proper height and breaks words
|
||||||
@apply p-0;
|
.tiptap.ProseMirror {
|
||||||
.ProseMirror {
|
flex: 1;
|
||||||
min-height: 600px !important;
|
min-height: 70px;
|
||||||
width: 90%;
|
overflow-y: auto;
|
||||||
scrollbar-width: none;
|
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 {
|
.tiptap {
|
||||||
a {
|
a {
|
||||||
color: #0066cc;
|
color: #0066cc;
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-wrap px-2 py-1">
|
<div class="flex flex-wrap">
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap">
|
||||||
<div
|
<div
|
||||||
v-for="action in actions"
|
v-for="action in actions"
|
||||||
:key="action.type"
|
: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
|
<component
|
||||||
:is="getIcon(action.type)"
|
:is="getIcon(action.type)"
|
||||||
size="16"
|
size="16"
|
||||||
class="text-primary group-hover:text-primary"
|
class="text-gray-500 text-primary group-hover:text-primary"
|
||||||
/>
|
/>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click.stop="onRemove(action)"
|
@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"
|
title="Remove action"
|
||||||
>
|
>
|
||||||
<X size="14" />
|
<X size="14" />
|
||||||
|
|||||||
@@ -3,328 +3,137 @@
|
|||||||
<!-- Fullscreen editor -->
|
<!-- Fullscreen editor -->
|
||||||
<Dialog :open="isEditorFullscreen" @update:open="isEditorFullscreen = false">
|
<Dialog :open="isEditorFullscreen" @update:open="isEditorFullscreen = false">
|
||||||
<DialogContent
|
<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"
|
@escapeKeyDown="isEditorFullscreen = false"
|
||||||
hide-close-button="true"
|
:hide-close-button="true"
|
||||||
>
|
>
|
||||||
<div v-if="isEditorFullscreen" class="h-full flex flex-col">
|
<ReplyBoxContent
|
||||||
<!-- Message type toggle -->
|
v-if="isEditorFullscreen"
|
||||||
<div class="flex justify-between items-center border-b border-border pb-4">
|
:isFullscreen="true"
|
||||||
<Tabs v-model="messageType" class="rounded-lg">
|
:aiPrompts="aiPrompts"
|
||||||
<TabsList class="bg-muted p-1 rounded-lg">
|
:isSending="isSending"
|
||||||
<TabsTrigger
|
:uploadingFiles="uploadingFiles"
|
||||||
value="reply"
|
:clearEditorContent="clearEditorContent"
|
||||||
class="px-3 py-1 rounded-lg transition-colors duration-200"
|
:htmlContent="htmlContent"
|
||||||
:class="{ 'bg-background text-foreground': messageType === 'reply' }"
|
:textContent="textContent"
|
||||||
>
|
:selectedText="selectedText"
|
||||||
Reply
|
:isBold="isBold"
|
||||||
</TabsTrigger>
|
:isItalic="isItalic"
|
||||||
<TabsTrigger
|
:cursorPosition="cursorPosition"
|
||||||
value="private_note"
|
:contentToSet="contentToSet"
|
||||||
class="px-3 py-1 rounded-lg transition-colors duration-200"
|
:cc="cc"
|
||||||
:class="{ 'bg-background text-foreground': messageType === 'private_note' }"
|
:bcc="bcc"
|
||||||
>
|
:emailErrors="emailErrors"
|
||||||
Private note
|
:messageType="messageType"
|
||||||
</TabsTrigger>
|
:showBcc="showBcc"
|
||||||
</TabsList>
|
@update:htmlContent="htmlContent = $event"
|
||||||
</Tabs>
|
@update:textContent="textContent = $event"
|
||||||
<span
|
@update:selectedText="selectedText = $event"
|
||||||
class="text-muted-foreground hover:text-foreground transition-colors duration-200 cursor-pointer"
|
@update:isBold="isBold = $event"
|
||||||
variant="ghost"
|
@update:isItalic="isItalic = $event"
|
||||||
@click="isEditorFullscreen = false"
|
@update:cursorPosition="cursorPosition = $event"
|
||||||
>
|
@toggleFullscreen="isEditorFullscreen = false"
|
||||||
<Minimize2 size="18" />
|
@update:messageType="messageType = $event"
|
||||||
</span>
|
@update:cc="cc = $event"
|
||||||
</div>
|
@update:bcc="bcc = $event"
|
||||||
|
@update:showBcc="showBcc = $event"
|
||||||
<!-- CC and BCC fields -->
|
@updateEmailErrors="emailErrors = $event"
|
||||||
<div class="space-y-3 p-4 border-b border-border" v-if="messageType === 'reply'">
|
@send="processSend"
|
||||||
<div class="flex items-center space-x-2">
|
@fileUpload="handleFileUpload"
|
||||||
<label class="w-12 text-sm font-medium text-muted-foreground">CC:</label>
|
@inlineImageUpload="handleInlineImageUpload"
|
||||||
<Input
|
@fileDelete="handleOnFileDelete"
|
||||||
type="text"
|
@aiPromptSelected="handleAiPromptSelected"
|
||||||
placeholder="Email addresses separated by comma"
|
class="h-full flex-grow"
|
||||||
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>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<!-- Main Editor non-fullscreen -->
|
<!-- Main Editor non-fullscreen -->
|
||||||
<div class="bg-card text-card-foreground box px-2 pt-2 m-2">
|
<div
|
||||||
<div v-if="!isEditorFullscreen" class="">
|
class="bg-card text-card-foreground box m-2 px-2 pt-2 flex flex-col"
|
||||||
<!-- Message type toggle -->
|
v-if="!isEditorFullscreen"
|
||||||
<div class="flex justify-between items-center mb-4">
|
>
|
||||||
<Tabs v-model="messageType" class="rounded-lg">
|
<ReplyBoxContent
|
||||||
<TabsList class="bg-muted p-1 rounded-lg">
|
:isFullscreen="false"
|
||||||
<TabsTrigger
|
:aiPrompts="aiPrompts"
|
||||||
value="reply"
|
:isSending="isSending"
|
||||||
class="px-3 py-1 rounded-lg transition-colors duration-200"
|
:uploadingFiles="uploadingFiles"
|
||||||
:class="{ 'bg-background text-foreground': messageType === 'reply' }"
|
:clearEditorContent="clearEditorContent"
|
||||||
>
|
:htmlContent="htmlContent"
|
||||||
Reply
|
:textContent="textContent"
|
||||||
</TabsTrigger>
|
:selectedText="selectedText"
|
||||||
<TabsTrigger
|
:isBold="isBold"
|
||||||
value="private_note"
|
:isItalic="isItalic"
|
||||||
class="px-3 py-1 rounded-lg transition-colors duration-200"
|
:cursorPosition="cursorPosition"
|
||||||
:class="{ 'bg-background text-foreground': messageType === 'private_note' }"
|
:contentToSet="contentToSet"
|
||||||
>
|
:cc="cc"
|
||||||
Private note
|
:bcc="bcc"
|
||||||
</TabsTrigger>
|
:emailErrors="emailErrors"
|
||||||
</TabsList>
|
:messageType="messageType"
|
||||||
</Tabs>
|
:showBcc="showBcc"
|
||||||
<span
|
@update:htmlContent="htmlContent = $event"
|
||||||
class="text-muted-foreground hover:text-foreground transition-colors duration-200 cursor-pointer mr-2"
|
@update:textContent="textContent = $event"
|
||||||
variant="ghost"
|
@update:selectedText="selectedText = $event"
|
||||||
@click="isEditorFullscreen = true"
|
@update:isBold="isBold = $event"
|
||||||
>
|
@update:isItalic="isItalic = $event"
|
||||||
<Maximize2 size="15" />
|
@update:cursorPosition="cursorPosition = $event"
|
||||||
</span>
|
@toggleFullscreen="isEditorFullscreen = true"
|
||||||
</div>
|
@update:messageType="messageType = $event"
|
||||||
|
@update:cc="cc = $event"
|
||||||
<div class="space-y-3 mb-4" v-if="messageType === 'reply'">
|
@update:bcc="bcc = $event"
|
||||||
<div class="flex items-center space-x-2">
|
@update:showBcc="showBcc = $event"
|
||||||
<label class="w-12 text-sm font-medium text-muted-foreground">CC:</label>
|
@updateEmailErrors="emailErrors = $event"
|
||||||
<Input
|
@send="processSend"
|
||||||
type="text"
|
@fileUpload="handleFileUpload"
|
||||||
placeholder="Email addresses separated by comma"
|
@inlineImageUpload="handleInlineImageUpload"
|
||||||
v-model="cc"
|
@fileDelete="handleOnFileDelete"
|
||||||
class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring"
|
@aiPromptSelected="handleAiPromptSelected"
|
||||||
@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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed, nextTick, watch } from 'vue'
|
import { ref, onMounted, nextTick, watch, computed } from 'vue'
|
||||||
import { transformImageSrcToCID } from '@/utils/strings'
|
import { transformImageSrcToCID } from '@/utils/strings'
|
||||||
import { handleHTTPError } from '@/utils/http'
|
import { handleHTTPError } from '@/utils/http'
|
||||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||||
import { Maximize2, Minimize2 } from 'lucide-vue-next'
|
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
|
|
||||||
import Editor from './ConversationTextEditor.vue'
|
|
||||||
import { useConversationStore } from '@/stores/conversation'
|
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 { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
||||||
import { useEmitter } from '@/composables/useEmitter'
|
import { useEmitter } from '@/composables/useEmitter'
|
||||||
import AttachmentsPreview from '@/features/conversation/message/attachment/AttachmentsPreview.vue'
|
import ReplyBoxContent from '@/features/conversation/ReplyBoxContent.vue'
|
||||||
import MacroActionsPreview from '@/features/conversation/MacroActionsPreview.vue'
|
|
||||||
import ReplyBoxBottomMenuBar from '@/features/conversation/ReplyBoxMenuBar.vue'
|
|
||||||
|
|
||||||
const conversationStore = useConversationStore()
|
const conversationStore = useConversationStore()
|
||||||
const emitter = useEmitter()
|
const emitter = useEmitter()
|
||||||
const insertContent = ref(null)
|
|
||||||
const setInlineImage = ref(null)
|
// Shared state between the two editor components.
|
||||||
const clearEditorContent = ref(false)
|
const clearEditorContent = ref(false)
|
||||||
const isEditorFullscreen = ref(false)
|
const isEditorFullscreen = ref(false)
|
||||||
const isSending = 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 messageType = ref('reply')
|
||||||
const showBcc = ref(false)
|
|
||||||
const cc = ref('')
|
const cc = ref('')
|
||||||
const bcc = ref('')
|
const bcc = ref('')
|
||||||
|
const showBcc = ref(false)
|
||||||
const emailErrors = ref([])
|
const emailErrors = ref([])
|
||||||
const aiPrompts = ref([])
|
const aiPrompts = ref([])
|
||||||
const uploadingFiles = 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 () => {
|
onMounted(async () => {
|
||||||
await fetchAiPrompts()
|
await fetchAiPrompts()
|
||||||
})
|
})
|
||||||
|
|
||||||
const hideBcc = () => {
|
/**
|
||||||
showBcc.value = !showBcc.value
|
* Fetches AI prompts from the server.
|
||||||
}
|
*/
|
||||||
|
|
||||||
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 }
|
|
||||||
)
|
|
||||||
|
|
||||||
const fetchAiPrompts = async () => {
|
const fetchAiPrompts = async () => {
|
||||||
try {
|
try {
|
||||||
const resp = await api.getAiPrompts()
|
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) => {
|
const handleAiPromptSelected = async (key) => {
|
||||||
try {
|
try {
|
||||||
const resp = await api.aiCompletion({
|
const resp = await api.aiCompletion({
|
||||||
prompt_key: key,
|
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) {
|
} catch (error) {
|
||||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
@@ -354,33 +172,11 @@ const handleAiPromptSelected = async (key) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleBold = () => {
|
/**
|
||||||
isBold.value = !isBold.value
|
* 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 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
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleFileUpload = (event) => {
|
const handleFileUpload = (event) => {
|
||||||
const files = Array.from(event.target.files)
|
const files = Array.from(event.target.files)
|
||||||
uploadingFiles.value = files
|
uploadingFiles.value = files
|
||||||
@@ -407,6 +203,7 @@ const handleFileUpload = (event) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inline image upload is not supported yet.
|
||||||
const handleInlineImageUpload = (event) => {
|
const handleInlineImageUpload = (event) => {
|
||||||
for (const file of event.target.files) {
|
for (const file of event.target.files) {
|
||||||
api
|
api
|
||||||
@@ -416,12 +213,13 @@ const handleInlineImageUpload = (event) => {
|
|||||||
linked_model: 'messages'
|
linked_model: 'messages'
|
||||||
})
|
})
|
||||||
.then((resp) => {
|
.then((resp) => {
|
||||||
setInlineImage.value = {
|
const imageData = {
|
||||||
src: resp.data.data.url,
|
src: resp.data.data.url,
|
||||||
alt: resp.data.data.filename,
|
alt: resp.data.data.filename,
|
||||||
title: resp.data.data.uuid
|
title: resp.data.data.uuid
|
||||||
}
|
}
|
||||||
conversationStore.conversation.mediaFiles.push(resp.data.data)
|
conversationStore.conversation.mediaFiles.push(resp.data.data)
|
||||||
|
return imageData
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
@@ -433,44 +231,23 @@ const handleInlineImageUpload = (event) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const validateEmails = (field) => {
|
/**
|
||||||
const emails = field === 'cc' ? cc.value : bcc.value
|
* Returns true if the editor has text content.
|
||||||
const emailList = emails
|
*/
|
||||||
.split(',')
|
const hasTextContent = computed(() => {
|
||||||
.map((e) => e.trim())
|
return textContent.value.trim().length > 0
|
||||||
.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
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes the send action.
|
||||||
|
*/
|
||||||
|
const processSend = async () => {
|
||||||
isEditorFullscreen.value = false
|
isEditorFullscreen.value = false
|
||||||
try {
|
try {
|
||||||
isSending.value = true
|
isSending.value = true
|
||||||
|
|
||||||
// Send message if there is text content in the editor.
|
// Send message if there is text content in the editor.
|
||||||
if (hasTextContent.value) {
|
if (hasTextContent.value > 0) {
|
||||||
// Replace inline image url with cid.
|
// Replace inline image url with cid.
|
||||||
const message = transformImageSrcToCID(htmlContent.value)
|
const message = transformImageSrcToCID(htmlContent.value)
|
||||||
|
|
||||||
@@ -490,7 +267,7 @@ const handleSend = async () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
await api.sendMessage(conversationStore.current.uuid, {
|
await api.sendMessage(conversationStore.current.uuid, {
|
||||||
private: messageType.value === 'private_note',
|
private: messageType.value === 'private',
|
||||||
message: message,
|
message: message,
|
||||||
attachments: conversationStore.conversation.mediaFiles.map((file) => file.id),
|
attachments: conversationStore.conversation.mediaFiles.map((file) => file.id),
|
||||||
// Convert email addresses to array and remove empty strings.
|
// Convert email addresses to array and remove empty strings.
|
||||||
@@ -498,7 +275,7 @@ const handleSend = async () => {
|
|||||||
.split(',')
|
.split(',')
|
||||||
.map((email) => email.trim())
|
.map((email) => email.trim())
|
||||||
.filter((email) => email),
|
.filter((email) => email),
|
||||||
bcc: showBcc.value
|
bcc: bcc.value
|
||||||
? bcc.value
|
? bcc.value
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((email) => email.trim())
|
.map((email) => email.trim())
|
||||||
@@ -524,6 +301,7 @@ const handleSend = async () => {
|
|||||||
} finally {
|
} finally {
|
||||||
isSending.value = false
|
isSending.value = false
|
||||||
clearEditorContent.value = true
|
clearEditorContent.value = true
|
||||||
|
// Reset media and macro in conversation store.
|
||||||
conversationStore.resetMacro()
|
conversationStore.resetMacro()
|
||||||
conversationStore.resetMediaFiles()
|
conversationStore.resetMediaFiles()
|
||||||
emailErrors.value = []
|
emailErrors.value = []
|
||||||
@@ -531,33 +309,64 @@ const handleSend = async () => {
|
|||||||
clearEditorContent.value = false
|
clearEditorContent.value = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// Update assignee last seen timestamp.
|
||||||
api.updateAssigneeLastSeen(conversationStore.current.uuid)
|
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) => {
|
const handleOnFileDelete = (uuid) => {
|
||||||
conversationStore.conversation.mediaFiles = conversationStore.conversation.mediaFiles.filter(
|
conversationStore.conversation.mediaFiles = conversationStore.conversation.mediaFiles.filter(
|
||||||
(item) => item.uuid !== uuid
|
(item) => item.uuid !== uuid
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEmojiSelect = (emoji) => {
|
/**
|
||||||
insertContent.value = undefined
|
* Watches for changes in the conversation's macro and updates the editor content with the macro content.
|
||||||
// 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.
|
|
||||||
watch(
|
watch(
|
||||||
() => conversationStore.conversation.macro,
|
() => conversationStore.conversation.macro,
|
||||||
() => {
|
() => {
|
||||||
// hack: Quill editor adds <p><br></p> replace with <p></p>
|
// 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) {
|
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><br><\/p>/g,
|
||||||
'<p></p>'
|
'<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 }
|
{ 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>
|
</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" />
|
<Smile class="h-4 w-4" />
|
||||||
</Toggle>
|
</Toggle>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -52,11 +54,10 @@ const attachmentInput = ref(null)
|
|||||||
const inlineImageInput = ref(null)
|
const inlineImageInput = ref(null)
|
||||||
const isEmojiPickerVisible = ref(false)
|
const isEmojiPickerVisible = ref(false)
|
||||||
const emojiPickerRef = ref(null)
|
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({
|
defineProps({
|
||||||
isBold: Boolean,
|
|
||||||
isItalic: Boolean,
|
|
||||||
isSending: Boolean,
|
isSending: Boolean,
|
||||||
enableSend: Boolean,
|
enableSend: Boolean,
|
||||||
handleSend: Function,
|
handleSend: Function,
|
||||||
@@ -69,7 +70,11 @@ onClickOutside(emojiPickerRef, () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const triggerFileUpload = () => {
|
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 = () => {
|
const toggleEmojiPicker = () => {
|
||||||
|
|||||||
@@ -19,7 +19,11 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<!-- Message Content -->
|
<!-- 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 -->
|
<!-- Attachments -->
|
||||||
<MessageAttachmentPreview :attachments="nonInlineAttachments" />
|
<MessageAttachmentPreview :attachments="nonInlineAttachments" />
|
||||||
@@ -125,3 +129,9 @@ const retryMessage = (msg) => {
|
|||||||
api.retryMessage(convStore.current.uuid, msg.uuid)
|
api.retryMessage(convStore.current.uuid, msg.uuid)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.overflow-wrap-anywhere {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
leave-from-class="opacity-100 translate-y-0"
|
leave-from-class="opacity-100 translate-y-0"
|
||||||
leave-to-class="opacity-0 translate-y-1"
|
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
|
<button
|
||||||
@click="handleScrollToBottom"
|
@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"
|
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>
|
<template>
|
||||||
<div class="flex flex-1 flex-col gap-x-5 box p-5 space-y-5 bg-white">
|
<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">
|
<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">
|
<div class="bg-green-100/70 flex items-center space-x-2 px-1 rounded">
|
||||||
<span class="blinking-dot"></span>
|
<span class="blinking-dot"></span>
|
||||||
<p class="uppercase text-xs">Live</p>
|
<p class="uppercase text-xs">Live</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between pr-32">
|
<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-muted-foreground">{{ labels[key] }}</span>
|
||||||
<span class="text-2xl font-medium">{{ value }}</span>
|
<span class="text-2xl font-medium">{{ item }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
defineProps({
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
counts: { type: Object, required: true },
|
counts: { type: Object, required: true },
|
||||||
labels: { type: Object, required: true },
|
labels: { type: Object, required: true },
|
||||||
title: { type: String, 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>
|
</script>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import mitt from 'mitt'
|
|||||||
import api from './api'
|
import api from './api'
|
||||||
import './assets/styles/main.scss'
|
import './assets/styles/main.scss'
|
||||||
import './utils/strings.js'
|
import './utils/strings.js'
|
||||||
|
import VueDOMPurifyHTML from 'vue-dompurify-html'
|
||||||
import Root from './Root.vue'
|
import Root from './Root.vue'
|
||||||
|
|
||||||
const setFavicon = (url) => {
|
const setFavicon = (url) => {
|
||||||
@@ -50,6 +51,7 @@ async function initApp () {
|
|||||||
|
|
||||||
app.use(router)
|
app.use(router)
|
||||||
app.use(i18n)
|
app.use(i18n)
|
||||||
|
app.use(VueDOMPurifyHTML)
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ const routes = [
|
|||||||
path: '',
|
path: '',
|
||||||
name: 'team-inbox',
|
name: 'team-inbox',
|
||||||
component: InboxView,
|
component: InboxView,
|
||||||
props: true,
|
|
||||||
meta: { title: 'Team inbox' }
|
meta: { title: 'Team inbox' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -88,7 +87,6 @@ const routes = [
|
|||||||
path: '',
|
path: '',
|
||||||
name: 'view-inbox',
|
name: 'view-inbox',
|
||||||
component: InboxView,
|
component: InboxView,
|
||||||
props: true,
|
|
||||||
meta: { title: 'View inbox' }
|
meta: { title: 'View inbox' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -118,7 +116,6 @@ const routes = [
|
|||||||
path: '',
|
path: '',
|
||||||
name: 'inbox',
|
name: 'inbox',
|
||||||
component: InboxView,
|
component: InboxView,
|
||||||
props: true,
|
|
||||||
meta: {
|
meta: {
|
||||||
title: 'Inbox',
|
title: 'Inbox',
|
||||||
type: route => route.params.type === 'assigned' ? 'My inbox' : route.params.type
|
type: route => route.params.type === 'assigned' ? 'My inbox' : route.params.type
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { defineStore } from 'pinia'
|
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 { CONVERSATION_LIST_TYPE, CONVERSATION_DEFAULT_STATUSES } from '@/constants/conversation'
|
||||||
import { handleHTTPError } from '@/utils/http'
|
import { handleHTTPError } from '@/utils/http'
|
||||||
import { useEmitter } from '@/composables/useEmitter'
|
import { useEmitter } from '@/composables/useEmitter'
|
||||||
@@ -110,8 +110,11 @@ export const useConversationStore = defineStore('conversation', () => {
|
|||||||
clearInterval(reRenderInterval)
|
clearInterval(reRenderInterval)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setMacro (macros) {
|
async function setMacro (macro) {
|
||||||
conversation.macro = macros
|
// Clear existing macro.
|
||||||
|
conversation.macro = {}
|
||||||
|
await nextTick()
|
||||||
|
conversation.macro = macro
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeMacroAction (action) {
|
function removeMacroAction (action) {
|
||||||
@@ -231,6 +234,10 @@ export const useConversationStore = defineStore('conversation', () => {
|
|||||||
return conversation.data || {}
|
return conversation.data || {}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const hasConversationOpen = computed(() => {
|
||||||
|
return Object.keys(conversation.data || {}).length > 0
|
||||||
|
})
|
||||||
|
|
||||||
const currentBCC = computed(() => {
|
const currentBCC = computed(() => {
|
||||||
return conversation.data?.bcc || []
|
return conversation.data?.bcc || []
|
||||||
})
|
})
|
||||||
@@ -282,8 +289,10 @@ export const useConversationStore = defineStore('conversation', () => {
|
|||||||
async function fetchMessages (uuid, fetchNextPage = false) {
|
async function fetchMessages (uuid, fetchNextPage = false) {
|
||||||
// Messages are already cached?
|
// Messages are already cached?
|
||||||
let hasMessages = messages.data.getAllPagesMessages(uuid)
|
let hasMessages = messages.data.getAllPagesMessages(uuid)
|
||||||
if (hasMessages.length > 0 && !fetchNextPage)
|
if (hasMessages.length > 0 && !fetchNextPage) {
|
||||||
|
markConversationAsRead(uuid)
|
||||||
return
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch messages from server.
|
// Fetch messages from server.
|
||||||
messages.loading = true
|
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 response = await api.getConversationMessages(uuid, { page: page, page_size: MESSAGE_LIST_PAGE_SIZE })
|
||||||
const result = response.data?.data || {}
|
const result = response.data?.data || {}
|
||||||
const newMessages = result.results || []
|
const newMessages = result.results || []
|
||||||
// Mark conversation as read
|
|
||||||
markConversationAsRead(uuid)
|
markConversationAsRead(uuid)
|
||||||
// Cache messages
|
// Cache messages
|
||||||
messages.data.addMessages(uuid, newMessages, result.page, result.total_pages)
|
messages.data.addMessages(uuid, newMessages, result.page, result.total_pages)
|
||||||
@@ -608,8 +616,8 @@ export const useConversationStore = defineStore('conversation', () => {
|
|||||||
Object.assign(conversation, {
|
Object.assign(conversation, {
|
||||||
data: null,
|
data: null,
|
||||||
participants: {},
|
participants: {},
|
||||||
macro: {},
|
|
||||||
mediaFiles: [],
|
mediaFiles: [],
|
||||||
|
macro: {},
|
||||||
loading: false,
|
loading: false,
|
||||||
errorMessage: ''
|
errorMessage: ''
|
||||||
})
|
})
|
||||||
@@ -629,6 +637,7 @@ export const useConversationStore = defineStore('conversation', () => {
|
|||||||
conversationsList,
|
conversationsList,
|
||||||
conversationMessages,
|
conversationMessages,
|
||||||
currentConversationHasMoreMessages,
|
currentConversationHasMoreMessages,
|
||||||
|
hasConversationOpen,
|
||||||
current,
|
current,
|
||||||
currentContactName,
|
currentContactName,
|
||||||
currentBCC,
|
currentBCC,
|
||||||
|
|||||||
@@ -15,14 +15,15 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
avatar_url: '',
|
avatar_url: '',
|
||||||
email: '',
|
email: '',
|
||||||
teams: [],
|
teams: [],
|
||||||
permissions: []
|
permissions: [],
|
||||||
|
availability_status: 'offline'
|
||||||
})
|
})
|
||||||
const emitter = useEmitter()
|
const emitter = useEmitter()
|
||||||
|
|
||||||
const userID = computed(() => user.value.id)
|
const userID = computed(() => user.value.id)
|
||||||
const firstName = computed(() => user.value.first_name)
|
const firstName = computed(() => user.value.first_name || '')
|
||||||
const lastName = computed(() => user.value.last_name)
|
const lastName = computed(() => user.value.last_name || '')
|
||||||
const avatar = computed(() => user.value.avatar_url)
|
const avatar = computed(() => user.value.avatar_url || '')
|
||||||
const permissions = computed(() => user.value.permissions || [])
|
const permissions = computed(() => user.value.permissions || [])
|
||||||
const email = computed(() => user.value.email)
|
const email = computed(() => user.value.email)
|
||||||
const teams = computed(() => user.value.teams || [])
|
const teams = computed(() => user.value.teams || [])
|
||||||
@@ -71,6 +72,10 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setCurrentUser = (userData) => {
|
||||||
|
user.value = userData
|
||||||
|
}
|
||||||
|
|
||||||
const setAvatar = (avatarURL) => {
|
const setAvatar = (avatarURL) => {
|
||||||
if (typeof avatarURL !== 'string') {
|
if (typeof avatarURL !== 'string') {
|
||||||
console.warn('Avatar URL must be a string')
|
console.warn('Avatar URL must be a string')
|
||||||
@@ -83,6 +88,16 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
user.value.avatar_url = ''
|
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 {
|
return {
|
||||||
user,
|
user,
|
||||||
userID,
|
userID,
|
||||||
@@ -96,9 +111,11 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
getInitials,
|
getInitials,
|
||||||
hasAdminTabPermissions,
|
hasAdminTabPermissions,
|
||||||
hasReportTabPermissions,
|
hasReportTabPermissions,
|
||||||
|
setCurrentUser,
|
||||||
getCurrentUser,
|
getCurrentUser,
|
||||||
clearAvatar,
|
clearAvatar,
|
||||||
setAvatar,
|
setAvatar,
|
||||||
|
updateUserAvailability,
|
||||||
can
|
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>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { watch, onMounted } from 'vue'
|
import { watch, onMounted, onUnmounted } from 'vue'
|
||||||
import { useConversationStore } from '@/stores/conversation'
|
import { useConversationStore } from '@/stores/conversation'
|
||||||
import Conversation from '@/features/conversation/Conversation.vue'
|
import Conversation from '@/features/conversation/Conversation.vue'
|
||||||
import ConversationSideBarWrapper from '@/features/conversation/sidebar/ConversationSideBarWrapper.vue'
|
import ConversationSideBarWrapper from '@/features/conversation/sidebar/ConversationSideBarWrapper.vue'
|
||||||
@@ -37,6 +37,10 @@ onMounted(() => {
|
|||||||
if (props.uuid) fetchConversation(props.uuid)
|
if (props.uuid) fetchConversation(props.uuid)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
conversationStore.resetCurrentConversation()
|
||||||
|
})
|
||||||
|
|
||||||
// Watcher for UUID changes
|
// Watcher for UUID changes
|
||||||
watch(
|
watch(
|
||||||
() => props.uuid,
|
() => props.uuid,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<ConversationPlaceholder v-if="route.name === 'inbox'" />
|
<ConversationPlaceholder v-if="['inbox', 'team-inbox', 'view-inbox'].includes(route.name)" />
|
||||||
<router-view />
|
<router-view />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -138,12 +138,14 @@ import { Card, CardContent, CardTitle } from '@/components/ui/card'
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { useEmitter } from '@/composables/useEmitter'
|
import { useEmitter } from '@/composables/useEmitter'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||||
|
|
||||||
const emitter = useEmitter()
|
const emitter = useEmitter()
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const userStore = useUserStore()
|
||||||
const loginForm = ref({
|
const loginForm = ref({
|
||||||
email: '',
|
email: '',
|
||||||
password: ''
|
password: ''
|
||||||
@@ -207,7 +209,10 @@ const loginAction = () => {
|
|||||||
email: loginForm.value.email,
|
email: loginForm.value.email,
|
||||||
password: loginForm.value.password
|
password: loginForm.value.password
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then((resp) => {
|
||||||
|
if (resp?.data?.data) {
|
||||||
|
userStore.setCurrentUser(resp.data.data)
|
||||||
|
}
|
||||||
router.push({ name: 'inboxes' })
|
router.push({ name: 'inboxes' })
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
|||||||
@@ -76,8 +76,7 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="p-6 text-center">
|
<footer class="p-6 text-center">
|
||||||
<div class="text-sm text-muted-foreground space-x-4">
|
<div class="text-sm text-muted-foreground space-x-4"></div>
|
||||||
</div>
|
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -93,10 +92,13 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Error } from '@/components/ui/error'
|
import { Error } from '@/components/ui/error'
|
||||||
import { Card, CardContent, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardTitle } from '@/components/ui/card'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||||
|
import { useEmitter } from '@/composables/useEmitter'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
|
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
const emitter = useEmitter()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const resetForm = ref({
|
const resetForm = ref({
|
||||||
email: ''
|
email: ''
|
||||||
@@ -121,16 +123,16 @@ const requestResetAction = async () => {
|
|||||||
await api.resetPassword({
|
await api.resetPassword({
|
||||||
email: resetForm.value.email
|
email: resetForm.value.email
|
||||||
})
|
})
|
||||||
toast({
|
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
title: 'Reset link sent',
|
title: 'Reset link sent',
|
||||||
description: 'Please check your email for the reset link.'
|
description: 'Please check your email for the reset link.'
|
||||||
})
|
})
|
||||||
router.push({ name: 'login' })
|
router.push({ name: 'login' })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
title: 'Error',
|
title: 'Reset link sent',
|
||||||
description: err.response.data.message,
|
variant: 'destructive',
|
||||||
variant: 'destructive'
|
description: handleHTTPError(err).message
|
||||||
})
|
})
|
||||||
errorMessage.value = handleHTTPError(err).message
|
errorMessage.value = handleHTTPError(err).message
|
||||||
useTemporaryClass('reset-password-container', 'animate-shake')
|
useTemporaryClass('reset-password-container', 'animate-shake')
|
||||||
|
|||||||
@@ -125,18 +125,16 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const validateForm = () => {
|
const validateForm = () => {
|
||||||
if (!passwordForm.value.password || passwordForm.value.password.length < 8) {
|
if (!passwordForm.value.password) {
|
||||||
errorMessage.value = 'Password must be at least 8 characters long.'
|
errorMessage.value = 'Password is required.'
|
||||||
useTemporaryClass('set-password-container', 'animate-shake')
|
useTemporaryClass('set-password-container', 'animate-shake')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (passwordForm.value.password !== passwordForm.value.confirmPassword) {
|
if (passwordForm.value.password !== passwordForm.value.confirmPassword) {
|
||||||
errorMessage.value = 'Passwords do not match.'
|
errorMessage.value = 'Passwords do not match.'
|
||||||
useTemporaryClass('set-password-container', 'animate-shake')
|
useTemporaryClass('set-password-container', 'animate-shake')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,11 +154,6 @@ const setPasswordAction = async () => {
|
|||||||
})
|
})
|
||||||
router.push({ name: 'login' })
|
router.push({ name: 'login' })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
|
||||||
title: 'Error',
|
|
||||||
variant: 'destructive',
|
|
||||||
description: handleHTTPError(err).message
|
|
||||||
})
|
|
||||||
errorMessage.value = handleHTTPError(err).message
|
errorMessage.value = handleHTTPError(err).message
|
||||||
useTemporaryClass('set-password-container', 'animate-shake')
|
useTemporaryClass('set-password-container', 'animate-shake')
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,27 +1,29 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div class="overflow-y-auto">
|
||||||
class="overflow-y-auto p-4 pr-36"
|
<div
|
||||||
:class="{ 'opacity-50 transition-opacity duration-300': isLoading }"
|
class="p-4 w-[calc(100%-3rem)]"
|
||||||
>
|
:class="{ 'opacity-50 transition-opacity duration-300': isLoading }"
|
||||||
<Spinner v-if="isLoading" />
|
>
|
||||||
<div class="space-y-4">
|
<Spinner v-if="isLoading" />
|
||||||
<div class="text-sm text-gray-500 text-right">
|
<div class="space-y-4">
|
||||||
Last updated: {{ new Date(lastUpdate).toLocaleTimeString() }}
|
<div class="text-sm text-gray-500 text-right">
|
||||||
</div>
|
Last updated: {{ new Date(lastUpdate).toLocaleTimeString() }}
|
||||||
<div class="mt-7 flex w-full space-x-4">
|
</div>
|
||||||
<Card title="Open conversations" :counts="cardCounts" :labels="agentCountCardsLabels" />
|
<div class="mt-7 flex w-full space-x-4">
|
||||||
<Card
|
<Card title="Open conversations" :counts="cardCounts" :labels="agentCountCardsLabels" />
|
||||||
class="w-8/12"
|
<Card
|
||||||
title="Agent status"
|
class="w-8/12"
|
||||||
:counts="sampleAgentStatusCounts"
|
title="Agent status"
|
||||||
:labels="sampleAgentStatusLabels"
|
:counts="agentStatusCounts"
|
||||||
/>
|
:labels="agentStatusLabels"
|
||||||
</div>
|
/>
|
||||||
<div class="rounded-lg box w-full p-5 bg-white">
|
</div>
|
||||||
<LineChart :data="chartData.processedData"></LineChart>
|
<div class="rounded-lg box w-full p-5 bg-white">
|
||||||
</div>
|
<LineChart :data="chartData.processedData"></LineChart>
|
||||||
<div class="rounded-lg box w-full p-5 bg-white">
|
</div>
|
||||||
<BarChart :data="chartData.status_summary"></BarChart>
|
<div class="rounded-lg box w-full p-5 bg-white">
|
||||||
|
<BarChart :data="chartData.status_summary"></BarChart>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,18 +54,18 @@ const agentCountCardsLabels = {
|
|||||||
pending: 'Pending'
|
pending: 'Pending'
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Build agent status feature.
|
const agentStatusLabels = {
|
||||||
const sampleAgentStatusLabels = {
|
agents_online: 'Online',
|
||||||
online: 'Online',
|
agents_offline: 'Offline',
|
||||||
offline: 'Offline',
|
agents_away: 'Away'
|
||||||
away: 'Away'
|
|
||||||
}
|
|
||||||
const sampleAgentStatusCounts = {
|
|
||||||
online: 5,
|
|
||||||
offline: 2,
|
|
||||||
away: 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const agentStatusCounts = ref({
|
||||||
|
agents_online: 0,
|
||||||
|
agents_offline: 0,
|
||||||
|
agents_away: 0
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
getDashboardData()
|
getDashboardData()
|
||||||
startRealtimeUpdates()
|
startRealtimeUpdates()
|
||||||
@@ -96,6 +98,11 @@ const getCardStats = async () => {
|
|||||||
.getOverviewCounts()
|
.getOverviewCounts()
|
||||||
.then((resp) => {
|
.then((resp) => {
|
||||||
cardCounts.value = resp.data.data
|
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) => {
|
.catch((error) => {
|
||||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const animate = require("tailwindcss-animate")
|
const animate = require("tailwindcss-animate")
|
||||||
|
const typography = require("@tailwindcss/typography")
|
||||||
|
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
@@ -140,5 +141,5 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [animate],
|
plugins: [animate, typography],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -234,7 +234,10 @@ SELECT json_build_object(
|
|||||||
'open', COUNT(*),
|
'open', COUNT(*),
|
||||||
'awaiting_response', COUNT(CASE WHEN c.waiting_since IS NOT NULL THEN 1 END),
|
'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),
|
'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
|
FROM conversations c
|
||||||
INNER JOIN conversation_statuses s ON c.status_id = s.id
|
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"
|
"github.com/volatiletech/null/v9"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
Online = "online"
|
||||||
|
Offline = "offline"
|
||||||
|
Away = "away"
|
||||||
|
AwayManual = "away_manual"
|
||||||
|
)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int `db:"id" json:"id,omitempty"`
|
ID int `db:"id" json:"id,omitempty"`
|
||||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||||
FirstName string `db:"first_name" json:"first_name"`
|
FirstName string `db:"first_name" json:"first_name"`
|
||||||
LastName string `db:"last_name" json:"last_name"`
|
LastName string `db:"last_name" json:"last_name"`
|
||||||
Email null.String `db:"email" json:"email,omitempty"`
|
Email null.String `db:"email" json:"email,omitempty"`
|
||||||
Type string `db:"type" json:"type"`
|
Type string `db:"type" json:"type"`
|
||||||
PhoneNumber null.String `db:"phone_number" json:"phone_number,omitempty"`
|
AvailabilityStatus string `db:"availability_status" json:"availability_status"`
|
||||||
AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
|
PhoneNumber null.String `db:"phone_number" json:"phone_number,omitempty"`
|
||||||
Enabled bool `db:"enabled" json:"enabled"`
|
AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
|
||||||
Password string `db:"password" json:"-"`
|
Enabled bool `db:"enabled" json:"enabled"`
|
||||||
Roles pq.StringArray `db:"roles" json:"roles,omitempty"`
|
Password string `db:"password" json:"-"`
|
||||||
Permissions pq.StringArray `db:"permissions" json:"permissions,omitempty"`
|
Roles pq.StringArray `db:"roles" json:"roles,omitempty"`
|
||||||
Meta pq.StringArray `db:"meta" json:"meta,omitempty"`
|
Permissions pq.StringArray `db:"permissions" json:"permissions,omitempty"`
|
||||||
CustomAttributes pq.StringArray `db:"custom_attributes" json:"custom_attributes,omitempty"`
|
Meta pq.StringArray `db:"meta" json:"meta,omitempty"`
|
||||||
Teams tmodels.Teams `db:"teams" json:"teams,omitempty"`
|
CustomAttributes pq.StringArray `db:"custom_attributes" json:"custom_attributes,omitempty"`
|
||||||
ContactChannelID int `db:"contact_channel_id" json:"contact_channel_id,omitempty"`
|
Teams tmodels.Teams `db:"teams" json:"teams,omitempty"`
|
||||||
NewPassword string `db:"-" json:"new_password,omitempty"`
|
ContactChannelID int `db:"contact_channel_id" json:"contact_channel_id,omitempty"`
|
||||||
SendWelcomeEmail bool `db:"-" json:"send_welcome_email,omitempty"`
|
NewPassword string `db:"-" json:"new_password,omitempty"`
|
||||||
InboxID int `json:"-"`
|
SendWelcomeEmail bool `db:"-" json:"send_welcome_email,omitempty"`
|
||||||
SourceChannel null.String `json:"-"`
|
InboxID int `json:"-"`
|
||||||
SourceChannelID null.String `json:"-"`
|
SourceChannel null.String `json:"-"`
|
||||||
|
SourceChannelID null.String `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *User) FullName() string {
|
func (u *User) FullName() string {
|
||||||
|
|||||||
@@ -20,41 +20,32 @@ SELECT email
|
|||||||
FROM users
|
FROM users
|
||||||
WHERE id = $1 AND deleted_at IS NULL AND type = 'agent';
|
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
|
-- name: get-user
|
||||||
SELECT
|
SELECT
|
||||||
u.id,
|
u.id,
|
||||||
u.created_at,
|
u.email,
|
||||||
u.updated_at,
|
u.password,
|
||||||
u.enabled,
|
u.created_at,
|
||||||
u.email,
|
u.updated_at,
|
||||||
u.avatar_url,
|
u.enabled,
|
||||||
u.first_name,
|
u.avatar_url,
|
||||||
u.last_name,
|
u.first_name,
|
||||||
array_agg(DISTINCT r.name) as roles,
|
u.last_name,
|
||||||
COALESCE(
|
u.availability_status,
|
||||||
(SELECT json_agg(json_build_object('id', t.id, 'name', t.name, 'emoji', t.emoji))
|
array_agg(DISTINCT r.name) as roles,
|
||||||
FROM team_members tm
|
COALESCE(
|
||||||
JOIN teams t ON tm.team_id = t.id
|
(SELECT json_agg(json_build_object('id', t.id, 'name', t.name, 'emoji', t.emoji))
|
||||||
WHERE tm.user_id = u.id),
|
FROM team_members tm
|
||||||
'[]'
|
JOIN teams t ON tm.team_id = t.id
|
||||||
) AS teams,
|
WHERE tm.user_id = u.id),
|
||||||
array_agg(DISTINCT p) as permissions
|
'[]'
|
||||||
|
) AS teams,
|
||||||
|
array_agg(DISTINCT p) as permissions
|
||||||
FROM users u
|
FROM users u
|
||||||
LEFT JOIN user_roles ur ON ur.user_id = u.id
|
LEFT JOIN user_roles ur ON ur.user_id = u.id
|
||||||
LEFT JOIN roles r ON r.id = ur.role_id,
|
LEFT JOIN roles r ON r.id = ur.role_id,
|
||||||
unnest(r.permissions) p
|
unnest(r.permissions) p
|
||||||
WHERE u.id = $1 AND u.deleted_at IS NULL AND u.type = 'agent'
|
WHERE (u.id = $1 OR u.email = $2) AND u.deleted_at IS NULL AND u.type = 'agent'
|
||||||
GROUP BY u.id;
|
GROUP BY u.id;
|
||||||
|
|
||||||
-- name: set-user-password
|
-- name: set-user-password
|
||||||
@@ -92,6 +83,22 @@ UPDATE users
|
|||||||
SET avatar_url = $2, updated_at = now()
|
SET avatar_url = $2, updated_at = now()
|
||||||
WHERE id = $1 AND type = 'agent';
|
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
|
-- name: get-permissions
|
||||||
SELECT DISTINCT unnest(r.permissions)
|
SELECT DISTINCT unnest(r.permissions)
|
||||||
FROM users u
|
FROM users u
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
@@ -61,13 +62,15 @@ type Opts struct {
|
|||||||
// queries contains prepared SQL queries.
|
// queries contains prepared SQL queries.
|
||||||
type queries struct {
|
type queries struct {
|
||||||
GetUsers *sqlx.Stmt `query:"get-users"`
|
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"`
|
GetUser *sqlx.Stmt `query:"get-user"`
|
||||||
GetEmail *sqlx.Stmt `query:"get-email"`
|
GetEmail *sqlx.Stmt `query:"get-email"`
|
||||||
GetPermissions *sqlx.Stmt `query:"get-permissions"`
|
GetPermissions *sqlx.Stmt `query:"get-permissions"`
|
||||||
GetUserByEmail *sqlx.Stmt `query:"get-user-by-email"`
|
|
||||||
UpdateUser *sqlx.Stmt `query:"update-user"`
|
UpdateUser *sqlx.Stmt `query:"update-user"`
|
||||||
UpdateAvatar *sqlx.Stmt `query:"update-avatar"`
|
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"`
|
SoftDeleteUser *sqlx.Stmt `query:"soft-delete-user"`
|
||||||
SetUserPassword *sqlx.Stmt `query:"set-user-password"`
|
SetUserPassword *sqlx.Stmt `query:"set-user-password"`
|
||||||
SetResetPasswordToken *sqlx.Stmt `query:"set-reset-password-token"`
|
SetResetPasswordToken *sqlx.Stmt `query:"set-reset-password-token"`
|
||||||
@@ -89,22 +92,19 @@ func New(i18n *i18n.I18n, opts Opts) (*Manager, error) {
|
|||||||
}, nil
|
}, 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) {
|
func (u *Manager) VerifyPassword(email string, password []byte) (models.User, error) {
|
||||||
var user models.User
|
var user models.User
|
||||||
|
if err := u.q.GetUser.Get(&user, 0, email); err != nil {
|
||||||
if err := u.q.GetUserByEmail.Get(&user, email); err != nil {
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return user, envelope.NewError(envelope.InputError, u.i18n.T("user.invalidEmailPassword"), nil)
|
return user, envelope.NewError(envelope.InputError, u.i18n.T("user.invalidEmailPassword"), nil)
|
||||||
}
|
}
|
||||||
u.lo.Error("error fetching user from db", "error", err)
|
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)
|
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 {
|
if err := u.verifyPassword(password, user.Password); err != nil {
|
||||||
return user, envelope.NewError(envelope.InputError, u.i18n.T("user.invalidEmailPassword"), nil)
|
return user, envelope.NewError(envelope.InputError, u.i18n.T("user.invalidEmailPassword"), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
return user, 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.
|
// GetAllCompact returns a compact list of users with limited fields.
|
||||||
func (u *Manager) GetAllCompact() ([]models.User, error) {
|
func (u *Manager) GetAllCompact() ([]models.User, error) {
|
||||||
var users = make([]models.User, 0)
|
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) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return users, nil
|
return users, nil
|
||||||
}
|
}
|
||||||
@@ -154,10 +154,10 @@ func (u *Manager) CreateAgent(user *models.User) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get retrieves a user by ID.
|
// Get retrieves an user by ID.
|
||||||
func (u *Manager) Get(id int) (models.User, error) {
|
func (u *Manager) Get(id int) (models.User, error) {
|
||||||
var user models.User
|
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) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
u.lo.Error("user not found", "id", id, "error", err)
|
u.lo.Error("user not found", "id", id, "error", err)
|
||||||
return user, envelope.NewError(envelope.GeneralError, "User not found", nil)
|
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
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetByEmail retrieves a user by email
|
// GetByEmail retrieves an user by email
|
||||||
func (u *Manager) GetByEmail(email string) (models.User, error) {
|
func (u *Manager) GetByEmail(email string) (models.User, error) {
|
||||||
var user models.User
|
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) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return user, envelope.NewError(envelope.GeneralError, "User not found", nil)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update updates a user.
|
// Update updates an user.
|
||||||
func (u *Manager) Update(id int, user models.User) error {
|
func (u *Manager) Update(id int, user models.User) error {
|
||||||
var (
|
var (
|
||||||
hashedPassword interface{}
|
hashedPassword any
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -221,7 +221,7 @@ func (u *Manager) Update(id int, user models.User) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SoftDelete soft deletes a user.
|
// SoftDelete soft deletes an user.
|
||||||
func (u *Manager) SoftDelete(id int) error {
|
func (u *Manager) SoftDelete(id int) error {
|
||||||
// Disallow if user is system user.
|
// Disallow if user is system user.
|
||||||
systemUser, err := u.GetSystemUser()
|
systemUser, err := u.GetSystemUser()
|
||||||
@@ -239,7 +239,7 @@ func (u *Manager) SoftDelete(id int) error {
|
|||||||
return nil
|
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) {
|
func (u *Manager) GetEmail(id int) (string, error) {
|
||||||
var email string
|
var email string
|
||||||
if err := u.q.GetEmail.Get(&email, id); err != nil {
|
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
|
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) {
|
func (u *Manager) SetResetPasswordToken(id int) (string, error) {
|
||||||
token, err := stringutil.RandomAlphanumeric(32)
|
token, err := stringutil.RandomAlphanumeric(32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -266,7 +266,7 @@ func (u *Manager) SetResetPasswordToken(id int) (string, error) {
|
|||||||
return token, nil
|
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 {
|
func (u *Manager) ResetPassword(token, password string) error {
|
||||||
if !u.isStrongPassword(password) {
|
if !u.isStrongPassword(password) {
|
||||||
return envelope.NewError(envelope.InputError, "Password is not strong enough, "+SystemUserPasswordHint, nil)
|
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)
|
u.lo.Error("error generating bcrypt password", "error", err)
|
||||||
return envelope.NewError(envelope.GeneralError, "Error setting new password", nil)
|
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)
|
u.lo.Error("error setting new password", "error", err)
|
||||||
return envelope.NewError(envelope.GeneralError, "Error setting new password", nil)
|
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
|
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) {
|
func (u *Manager) GetPermissions(id int) ([]string, error) {
|
||||||
var permissions []string
|
var permissions []string
|
||||||
if err := u.q.GetPermissions.Select(&permissions, id); err != nil {
|
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
|
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.
|
// verifyPassword compares the provided password with the stored password hash.
|
||||||
func (u *Manager) verifyPassword(pwd []byte, pwdHash string) error {
|
func (u *Manager) verifyPassword(pwd []byte, pwdHash string) error {
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(pwdHash), pwd); err != nil {
|
if err := bcrypt.CompareHashAndPassword([]byte(pwdHash), pwd); err != nil {
|
||||||
|
|||||||
@@ -94,7 +94,9 @@ func (c *Client) Listen() {
|
|||||||
|
|
||||||
// processIncomingMessage processes incoming messages from the client.
|
// processIncomingMessage processes incoming messages from the client.
|
||||||
func (c *Client) processIncomingMessage(data []byte) {
|
func (c *Client) processIncomingMessage(data []byte) {
|
||||||
|
// Handle ping messages, and update last active time for user.
|
||||||
if string(data) == "ping" {
|
if string(data) == "ping" {
|
||||||
|
c.Hub.userStore.UpdateLastActive(c.ID)
|
||||||
c.SendMessage([]byte("pong"), websocket.TextMessage)
|
c.SendMessage([]byte("pong"), websocket.TextMessage)
|
||||||
return
|
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.
|
// Client ID to WS Client map, user can connect from multiple devices and each device will have a separate client.
|
||||||
clients map[int][]*Client
|
clients map[int][]*Client
|
||||||
clientsMutex sync.Mutex
|
clientsMutex sync.Mutex
|
||||||
|
|
||||||
|
userStore userStore
|
||||||
|
}
|
||||||
|
|
||||||
|
type userStore interface {
|
||||||
|
UpdateLastActive(userID int) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHub creates a new websocket hub.
|
// NewHub creates a new websocket hub.
|
||||||
func NewHub() *Hub {
|
func NewHub(userStore userStore) *Hub {
|
||||||
return &Hub{
|
return &Hub{
|
||||||
clients: make(map[int][]*Client, 10000),
|
clients: make(map[int][]*Client, 10000),
|
||||||
clientsMutex: sync.Mutex{},
|
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 "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_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 "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.
|
-- Sequence to generate reference number for conversations.
|
||||||
DROP SEQUENCE IF EXISTS conversation_reference_number_sequence; CREATE SEQUENCE conversation_reference_number_sequence START 100;
|
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,
|
custom_attributes JSONB DEFAULT '{}'::jsonb NOT NULL,
|
||||||
reset_password_token TEXT NULL,
|
reset_password_token TEXT NULL,
|
||||||
reset_password_token_expiry TIMESTAMPTZ 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_country CHECK (LENGTH(country) <= 140),
|
||||||
CONSTRAINT constraint_users_on_phone_number CHECK (LENGTH(phone_number) <= 20),
|
CONSTRAINT constraint_users_on_phone_number CHECK (LENGTH(phone_number) <= 20),
|
||||||
CONSTRAINT constraint_users_on_email_length CHECK (LENGTH(email) <= 320),
|
CONSTRAINT constraint_users_on_email_length CHECK (LENGTH(email) <= 320),
|
||||||
|
|||||||
Reference in New Issue
Block a user