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