Compare commits

...

30 Commits

Author SHA1 Message Date
Abhinav Raut
71601364ae fix: mark conversation as read when messages are already cached 2025-02-26 17:41:25 +05:30
Abhinav Raut
44723fb70d fix: update command to start backend dev server in documentation 2025-02-26 12:11:15 +05:30
Abhinav Raut
67e1230485 feat: agent availability status
New columns in users table to store user availability status.

Websocket pings sets the last active at timestamp, once user stops sending pings (on disconnect) after 5 minutes the user availalbility status changes to offline.

Detects auto away by checking for mouse, keyboard events and sets user status to away.

User can also set their status to away manually from the sidebar.

Migrations for v0.3.0

Minor visual fixes.

Bump version in package.json
2025-02-26 04:34:30 +05:30
Abhinav Raut
d58898c60f fix: update DockerHub image path and branch reference in installation documentation 2025-02-26 00:52:42 +05:30
Abhinav Raut
a8dc0a6242 fix: correct DockerHub image path in installation documentation 2025-02-26 00:50:30 +05:30
Abhinav Raut
3aa144f703 feat: display app update component only for admin routes. 2025-02-25 18:27:21 +05:30
Abhinav Raut
fcbd16f042 feat: implement app update checker and UI notification 2025-02-25 02:49:09 +05:30
Abhinav Raut
e8f3f24422 feat: update build configuration and versioning system 2025-02-25 01:35:52 +05:30
Abhinav Raut
425bb4ed04 feat: add database upgrade functionality (adapted from listmonk) 2025-02-25 01:35:27 +05:30
Abhinav Raut
0c3da82250 fix: remove redundant margin class from Actions AccordionItem 2025-02-25 00:34:28 +05:30
Abhinav Raut
8649826a89 Update .gitattributes 2025-02-25 00:19:06 +05:30
Abhinav Raut
d427dfd20c Update .gitattributes 2025-02-25 00:18:42 +05:30
Abhinav Raut
afb54c371b Update .gitattributes 2025-02-25 00:16:28 +05:30
Abhinav Raut
46459599c7 Update .gitattributes 2025-02-25 00:10:14 +05:30
Abhinav Raut
63a6aedfd0 chore: update .gitattributes to specify Go language for all files 2025-02-25 00:08:04 +05:30
Abhinav Raut
ffbf613e68 chore: add .gitattributes to mark frontend files as vendored 2025-02-25 00:06:28 +05:30
Abhinav Raut
88f82fe80b fix: typos in docker image and curl request. 2025-02-24 22:28:01 +05:30
Abhinav Raut
914b6371b6 fix: goreleaser 2025-02-24 22:10:01 +05:30
Abhinav Raut
89eb05f337 feat: disallow edits for admin role
- chore: minor layout fixes
- chore: adds doc strings to handlers
2025-02-24 22:07:24 +05:30
Abhinav Raut
71a3588855 refactor: update help text for clarity across various admin views 2025-02-24 21:46:57 +05:30
Abhinav Raut
c6baf3f9bf fix: increase z-index for popper content wrapper to ensure visibility 2025-02-24 21:12:14 +05:30
Abhinav Raut
368ec3c82b fix: extend timeout for closing WebSocket connection on no pong response 2025-02-24 21:12:14 +05:30
Abhinav Raut
4cc40ec5d5 Update README.md 2025-02-24 03:41:58 +05:30
Abhinav Raut
171e404e6f chore: update screenshot URL in documentation 2025-02-24 03:40:53 +05:30
Abhinav Raut
28f4fda274 Update README.md 2025-02-24 03:31:30 +05:30
Abhinav Raut
00ded9c19b Update README.md 2025-02-24 03:30:28 +05:30
Abhinav Raut
17efaf0f2c fix: update password strength requirements and hint for system users 2025-02-24 02:42:27 +05:30
Abhinav Raut
b44290a6f0 feat: add GitHub Actions workflow for MkDocs deployment
- fix: inject correct vars in go releaser builds
2025-02-24 02:27:06 +05:30
Abhinav Raut
1a7ee4d8c6 chore: clean and remove unused UI components and update DotLoader usage 2025-02-23 22:40:09 +05:30
Abhinav Raut
ab56d01e22 chore: remove unused ConversationActions and DashboardGreet components 2025-02-23 21:50:43 +05:30
86 changed files with 1133 additions and 2328 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
VERSION export-subst

31
.github/workflows/github-pages.yml vendored Normal file
View File

@@ -0,0 +1,31 @@
name: Deploy MkDocs
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: 3.x
- run: pip install mkdocs-material
- run: |
if [ -f requirements.txt ]; then
pip install -r requirements.txt;
fi
- run: cd docs && mkdocs build
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./docs/site

1
.gitignore vendored
View File

@@ -7,3 +7,4 @@ libredesk.exe
uploads uploads
.env .env
dist/ dist/
.vscode/

View File

@@ -10,7 +10,7 @@ before:
- make frontend-build - make frontend-build
builds: builds:
- id: "standard" - id: "universal"
main: ./cmd main: ./cmd
env: env:
- CGO_ENABLED=0 - CGO_ENABLED=0
@@ -24,29 +24,13 @@ builds:
goarch: goarch:
- amd64 - amd64
- arm64 - arm64
binary: 'libredesk{{ if eq .Os "windows" }}.exe{{ end }}'
ldflags:
- -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
hooks:
post: make stuff BIN={{ .Path }}
- id: "arm"
main: ./cmd
env:
- CGO_ENABLED=0
goos:
- freebsd
- linux
- netbsd
- openbsd
goarch:
- arm - arm
goarm: goarm:
- 6 - 6
- 7 - 7
binary: 'libredesk{{ if eq .Os "windows" }}.exe{{ end }}' binary: 'libredesk{{ if eq .Os "windows" }}.exe{{ end }}'
ldflags: ldflags:
- -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} - -s -w -X "main.buildString={{ .Tag }} ({{ .ShortCommit }} {{ .Date }}, {{ .Os }}/{{ .Arch }})" -X "main.versionString={{ .Tag }}"
hooks: hooks:
post: make stuff BIN={{ .Path }} post: make stuff BIN={{ .Path }}
@@ -70,7 +54,7 @@ dockers:
goos: linux goos: linux
goarch: amd64 goarch: amd64
ids: ids:
- standard - universal
image_templates: image_templates:
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-amd64" - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-amd64"
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-amd64" - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-amd64"
@@ -94,7 +78,7 @@ dockers:
goos: linux goos: linux
goarch: arm64 goarch: arm64
ids: ids:
- standard - universal
image_templates: image_templates:
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-arm64" - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-arm64"
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-arm64" - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-arm64"
@@ -119,7 +103,7 @@ dockers:
goarch: arm goarch: arm
goarm: 6 goarm: 6
ids: ids:
- arm - universal
image_templates: image_templates:
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-armv6" - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-armv6"
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv6" - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv6"
@@ -144,7 +128,7 @@ dockers:
goarch: arm goarch: arm
goarm: 7 goarm: 7
ids: ids:
- arm - universal
image_templates: image_templates:
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-armv7" - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-armv7"
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv7" - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv7"

View File

@@ -1,8 +1,10 @@
# Build variables # Try to get the commit hash from 1) git 2) the VERSION file 3) fallback.
LAST_COMMIT := $(shell git rev-parse --short HEAD) LAST_COMMIT := $(or $(shell git rev-parse --short HEAD 2> /dev/null),$(shell head -n 1 VERSION | grep -oP -m 1 "^[a-z0-9]+$$"), "")
LAST_COMMIT_DATE := $(shell git show -s --format=%ci ${LAST_COMMIT})
VERSION := $(shell git describe --tags) # Try to get the semver from 1) git 2) the VERSION file 3) fallback.
BUILDSTR := ${VERSION} (Commit: ${LAST_COMMIT_DATE} (${LAST_COMMIT}), Build: $(shell date +"%Y-%m-%d %H:%M:%S %z")) VERSION := $(or $(LIBREDESK_VERSION),$(shell git describe --tags --abbrev=0 2> /dev/null),$(shell grep -oP 'tag: \Kv\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?' VERSION),"v0.0.0")
BUILDSTR := ${VERSION} (\#${LAST_COMMIT} $(shell date -u +"%Y-%m-%dT%H:%M:%S%z"))
# Binary names and paths # Binary names and paths
BIN := libredesk BIN := libredesk
@@ -30,13 +32,13 @@ install-deps: $(STUFFBIN)
.PHONY: frontend-build .PHONY: frontend-build
frontend-build: install-deps frontend-build: install-deps
@echo "→ Building frontend for production..." @echo "→ Building frontend for production..."
@cd ${FRONTEND_DIR} && pnpm build @export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build
# Run the Go backend server in development mode. # Run the Go backend server in development mode.
.PHONY: run-backend .PHONY: run-backend
run-backend: run-backend:
@echo "→ Running backend..." @echo "→ Running backend..."
@go run cmd/*.go CGO_ENABLED=0 go run -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -X 'main.frontendDir=frontend/dist'" cmd/*.go
# Run the JS frontend server in development mode. # Run the JS frontend server in development mode.
.PHONY: run-frontend .PHONY: run-frontend
@@ -44,19 +46,19 @@ run-frontend:
@echo "→ Installing frontend dependencies (if not already installed)..." @echo "→ Installing frontend dependencies (if not already installed)..."
@cd ${FRONTEND_DIR} && pnpm install @cd ${FRONTEND_DIR} && pnpm install
@echo "→ Running frontend..." @echo "→ Running frontend..."
@export VUE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm dev @export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm dev
# Build the backend binary. # Build the backend binary.
.PHONY: backend-build .PHONY: build-backend
backend-build: $(STUFFBIN) build-backend: $(STUFFBIN)
@echo "→ Building backend..." @echo "→ Building backend..."
@CGO_ENABLED=0 go build -a\ @CGO_ENABLED=0 go build -a\
-ldflags="-X 'main.buildString=${BUILDSTR}' -X 'main.buildDate=${LAST_COMMIT_DATE}' -s -w" \ -ldflags="-X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -s -w" \
-o ${BIN} cmd/*.go -o ${BIN} cmd/*.go
# Main build target: builds both frontend and backend, then stuffs static assets into the binary. # Main build target: builds both frontend and backend, then stuffs static assets into the binary.
.PHONY: build .PHONY: build
build: frontend-build backend-build stuff build: frontend-build build-backend stuff
@echo "→ Build successful. Current version: $(VERSION)" @echo "→ Build successful. Current version: $(VERSION)"
# Stuff static assets into the binary using stuffbin. # Stuff static assets into the binary using stuffbin.

View File

@@ -1,4 +1,4 @@
<a href="https://zerodha.tech"><img src="https://zerodha.tech/static/images/github-badge.svg" align="right" /></a> <a href="https://zerodha.tech"><img src="https://zerodha.tech/static/images/github-badge.svg" align="right" alt="Zerodha Tech Badge" /></a>
# Libredesk # Libredesk
@@ -10,37 +10,73 @@ Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**Live
![Screenshot_20250220_231723](https://github.com/user-attachments/assets/55e0ec68-b624-4442-8387-6157742da253) ![Screenshot_20250220_231723](https://github.com/user-attachments/assets/55e0ec68-b624-4442-8387-6157742da253)
> [!CAUTION] > **CAUTION:** This project is currently in **alpha**. Features and APIs may change and are not yet fully tested.
> This project is currently in **alpha**. Features and APIs may change and are not yet fully tested.
## Features
- **Multi Inbox**
Libredesk supports multiple inboxes, letting you manage conversations across teams effortlessly.
- **Granular Permissions**
Create custom roles with granular permissions for teams and individual agents.
- **Smart Automation**
Eliminate repetitive tasks with powerful automation rules. Auto-tag, assign, and route conversations based on custom conditions.
- **CSAT Surveys**
Measure customer satisfaction with automated surveys.
- **Macros**
Save frequently sent messages as templates. With one click, send saved responses, set tags, and more.
- **Smart Organization**
Keep conversations organized with tags, custom statuses for conversations, and snoozing. Find any conversation instantly from the search bar.
- **Auto Assignment**
Distribute workload with auto assignment rules. Auto-assign conversations based on agent capacity or custom criteria.
- **SLA Management**
Set and track response time targets. Get notified when conversations are at risk of breaching SLA commitments.
- **Business Intelligence**
Connect your favorite BI tools like Metabase and create custom dashboards and reports with your support data—without lock-ins.
- **AI-Assisted Response Rewrite**
Instantly rewrite responses with AI to make them more friendly, professional, or polished.
- **Command Bar**
Opens with a simple shortcut (CTRL+k) and lets you quickly perform actions on conversations.
And more checkout - [libredesk.io](https://libredesk.io)
## Developer Setup ## Installation
#### Prerequisites ### Docker
- **go** The latest image is available on DockerHub at [`libredesk/libredesk:latest`](https://hub.docker.com/r/libredesk/libredesk/tags?page=1&ordering=last_updated&name=latest)
- **pnpm**
- **postgreSQL >= 13**
- **redis**
1. **Clone the repository**: ```shell
# Download the compose file and sample config file in the current directory.
curl -LO https://github.com/abhinavxd/libredesk/raw/main/docker-compose.yml
curl -LO https://github.com/abhinavxd/libredesk/raw/main/config.sample.toml
```bash # Copy the config.sample.toml to config.toml and edit it as needed.
git clone https://github.com/abhinavxd/libredesk.git cp config.sample.toml config.toml
cd libredesk
# Run the services in the background.
docker compose up -d
# Setting System user password.
docker exec -it libredesk_app ./libredesk --set-system-user-password
``` ```
2. **Create config file**: Go to `http://localhost:9000` and login with username `System` and the password you set using the --set-system-user-password command.
- Copy the sample configuration file `config.toml.sample` to `config.toml`: See [installation docs](https://libredesk.io/docs/installation/)
```bash __________________
cp config.toml.sample config.toml
```
- Edit the `config.toml` file to configure your postgres and redis connection settings.
3. **Run in development mode**: ### Binary
- Download the [latest release](https://github.com/abhinavxd/libredesk/releases) and extract the libredesk binary.
- Copy config.sample.toml to config.toml and edit as needed.
- `./libredesk --install` to setup the Postgres DB (or `--upgrade` to upgrade an existing DB. Upgrades are idempotent and running them multiple times have no side effects).
- Run `./libredesk --set-system-user-password` to set the password for the System user.
- Run `./libredesk` and visit `http://localhost:9000` and login with username `System` and the password you set using the --set-system-user-password command.
- Backend: `make run-backend` See [installation docs](https://libredesk.app/docs/installation)
- Frontend: `make run-frontend` __________________
## Developers
If you are interested in contributing, refer to the [developer setup](https://libredesk.io/docs/developer-setup/). The backend is written in Go and the frontend is Vue js 3 with Shadcn for UI components.

2
VERSION Normal file
View File

@@ -0,0 +1,2 @@
$Format:%h$
$Format:%D$

View File

@@ -99,6 +99,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.GET("/api/v1/users/me", auth(handleGetCurrentUser)) g.GET("/api/v1/users/me", auth(handleGetCurrentUser))
g.PUT("/api/v1/users/me", auth(handleUpdateCurrentUser)) g.PUT("/api/v1/users/me", auth(handleUpdateCurrentUser))
g.GET("/api/v1/users/me/teams", auth(handleGetCurrentUserTeams)) g.GET("/api/v1/users/me/teams", auth(handleGetCurrentUserTeams))
g.PUT("/api/v1/users/me/availability", auth(handleUpdateUserAvailability))
g.DELETE("/api/v1/users/me/avatar", auth(handleDeleteAvatar)) g.DELETE("/api/v1/users/me/avatar", auth(handleDeleteAvatar))
g.GET("/api/v1/users/compact", auth(handleGetUsersCompact)) g.GET("/api/v1/users/compact", auth(handleGetUsersCompact))
g.GET("/api/v1/users", perm(handleGetUsers, "users:manage")) g.GET("/api/v1/users", perm(handleGetUsers, "users:manage"))

View File

@@ -308,6 +308,11 @@ func initCSAT(db *sqlx.DB) *csat.Manager {
return m return m
} }
// initWS inits websocket hub.
func initWS(user *user.Manager) *ws.Hub {
return ws.NewHub(user)
}
// initTemplates inits template manager. // initTemplates inits template manager.
func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants) *tmpl.Manager { func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants) *tmpl.Manager {
var ( var (
@@ -549,7 +554,7 @@ func initEmailInbox(inboxRecord imodels.Inbox, store inbox.MessageStore) (inbox.
return nil, fmt.Errorf("initializing `%s` inbox: `%s` error : %w", inboxRecord.Channel, inboxRecord.Name, err) return nil, fmt.Errorf("initializing `%s` inbox: `%s` error : %w", inboxRecord.Channel, inboxRecord.Name, err)
} }
log.Printf("`%s` inbox successfully initialized. %d SMTP servers. %d IMAP clients.", inboxRecord.Name, len(config.SMTP), len(config.IMAP)) log.Printf("`%s` inbox successfully initialized", inboxRecord.Name)
return inbox, nil return inbox, nil
} }

View File

@@ -9,10 +9,10 @@ import (
"time" "time"
"github.com/abhinavxd/libredesk/internal/colorlog" "github.com/abhinavxd/libredesk/internal/colorlog"
"github.com/abhinavxd/libredesk/internal/dbutil"
"github.com/abhinavxd/libredesk/internal/user" "github.com/abhinavxd/libredesk/internal/user"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/knadh/stuffbin" "github.com/knadh/stuffbin"
"github.com/lib/pq"
) )
// Install checks if the schema is already installed, prompts for confirmation, and installs the schema if needed. // Install checks if the schema is already installed, prompts for confirmation, and installs the schema if needed.
@@ -76,7 +76,7 @@ func setSystemUserPass(ctx context.Context, db *sqlx.DB) {
// checkSchema verifies if the DB schema is already installed by querying a table. // checkSchema verifies if the DB schema is already installed by querying a table.
func checkSchema(db *sqlx.DB) (bool, error) { func checkSchema(db *sqlx.DB) (bool, error) {
if _, err := db.Exec(`SELECT * FROM settings LIMIT 1`); err != nil { if _, err := db.Exec(`SELECT * FROM settings LIMIT 1`); err != nil {
if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "42P01" { if dbutil.IsTableNotExistError(err) {
return false, nil return false, nil
} }
return false, err return false, err

View File

@@ -3,6 +3,7 @@ package main
import ( import (
amodels "github.com/abhinavxd/libredesk/internal/auth/models" amodels "github.com/abhinavxd/libredesk/internal/auth/models"
"github.com/abhinavxd/libredesk/internal/envelope" "github.com/abhinavxd/libredesk/internal/envelope"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
"github.com/zerodha/fastglue" "github.com/zerodha/fastglue"
) )
@@ -11,14 +12,20 @@ import (
func handleLogin(r *fastglue.Request) error { func handleLogin(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
p = r.RequestCtx.PostArgs() email = string(r.RequestCtx.PostArgs().Peek("email"))
email = string(p.Peek("email")) password = r.RequestCtx.PostArgs().Peek("password")
password = p.Peek("password")
) )
user, err := app.user.VerifyPassword(email, password) user, err := app.user.VerifyPassword(email, password)
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
// Set user availability status to online.
if err := app.user.UpdateAvailability(user.ID, umodels.Online); err != nil {
return sendErrorEnvelope(r, err)
}
user.AvailabilityStatus = umodels.Online
if err := app.auth.SaveSession(amodels.User{ if err := app.auth.SaveSession(amodels.User{
ID: user.ID, ID: user.ID,
Email: user.Email.String, Email: user.Email.String,

View File

@@ -6,8 +6,10 @@ import (
"log" "log"
"os" "os"
"os/signal" "os/signal"
"sync"
"sync/atomic" "sync/atomic"
"syscall" "syscall"
"time"
"github.com/abhinavxd/libredesk/internal/ai" "github.com/abhinavxd/libredesk/internal/ai"
auth_ "github.com/abhinavxd/libredesk/internal/auth" auth_ "github.com/abhinavxd/libredesk/internal/auth"
@@ -34,7 +36,6 @@ import (
"github.com/abhinavxd/libredesk/internal/team" "github.com/abhinavxd/libredesk/internal/team"
"github.com/abhinavxd/libredesk/internal/template" "github.com/abhinavxd/libredesk/internal/template"
"github.com/abhinavxd/libredesk/internal/user" "github.com/abhinavxd/libredesk/internal/user"
"github.com/abhinavxd/libredesk/internal/ws"
"github.com/knadh/go-i18n" "github.com/knadh/go-i18n"
"github.com/knadh/koanf/v2" "github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin" "github.com/knadh/stuffbin"
@@ -50,7 +51,8 @@ var (
frontendDir = "frontend/dist" frontendDir = "frontend/dist"
// Injected at build time. // Injected at build time.
buildString = "" buildString string
versionString string
) )
// App is the global app context which is passed and injected in the http handlers. // App is the global app context which is passed and injected in the http handlers.
@@ -82,6 +84,10 @@ type App struct {
ai *ai.Manager ai *ai.Manager
search *search.Manager search *search.Manager
notifier *notifier.Service notifier *notifier.Service
// Global state that stores data on an available app update.
update *AppUpdate
sync.Mutex
} }
func main() { func main() {
@@ -99,9 +105,8 @@ func main() {
} }
// Build string injected at build time. // Build string injected at build time.
if buildString != "" {
colorlog.Green("Build: %s", buildString) colorlog.Green("Build: %s", buildString)
} colorlog.Green("Version: %s", versionString)
// Load the config files into Koanf. // Load the config files into Koanf.
initConfig(ko) initConfig(ko)
@@ -136,10 +141,13 @@ func main() {
// Upgrade. // Upgrade.
if ko.Bool("upgrade") { if ko.Bool("upgrade") {
log.Println("no upgrades available") upgrade(db, fs, !ko.Bool("yes"))
os.Exit(0) os.Exit(0)
} }
// Check for pending upgrade.
checkPendingUpgrade(db)
// Load app settings from DB into the Koanf instance. // Load app settings from DB into the Koanf instance.
settings := initSettings(db) settings := initSettings(db)
loadSettings(settings) loadSettings(settings)
@@ -153,7 +161,6 @@ func main() {
messageOutgoingScanInterval = ko.MustDuration("message.message_outoing_scan_interval") messageOutgoingScanInterval = ko.MustDuration("message.message_outoing_scan_interval")
slaEvaluationInterval = ko.MustDuration("sla.evaluation_interval") slaEvaluationInterval = ko.MustDuration("sla.evaluation_interval")
lo = initLogger(appName) lo = initLogger(appName)
wsHub = ws.NewHub()
rdb = initRedis() rdb = initRedis()
constants = initConstants() constants = initConstants()
i18n = initI18n(fs) i18n = initI18n(fs)
@@ -168,6 +175,7 @@ func main() {
team = initTeam(db) team = initTeam(db)
businessHours = initBusinessHours(db) businessHours = initBusinessHours(db)
user = initUser(i18n, db) user = initUser(i18n, db)
wsHub = initWS(user)
notifier = initNotifier(user) notifier = initNotifier(user)
automation = initAutomationEngine(db) automation = initAutomationEngine(db)
sla = initSLA(db, team, settings, businessHours) sla = initSLA(db, team, settings, businessHours)
@@ -184,6 +192,7 @@ func main() {
go notifier.Run(ctx) go notifier.Run(ctx)
go sla.Run(ctx, slaEvaluationInterval) go sla.Run(ctx, slaEvaluationInterval)
go media.DeleteUnlinkedMedia(ctx) go media.DeleteUnlinkedMedia(ctx)
go user.MonitorAgentAvailability(ctx)
var app = &App{ var app = &App{
lo: lo, lo: lo,
@@ -239,6 +248,11 @@ func main() {
} }
}() }()
// Start the app update checker.
if ko.Bool("app.check_updates") {
go checkUpdates(versionString, time.Hour*24, app)
}
// Wait for shutdown signal. // Wait for shutdown signal.
<-ctx.Done() <-ctx.Done()
colorlog.Red("Shutting down HTTP server...") colorlog.Red("Shutting down HTTP server...")

View File

@@ -43,9 +43,7 @@ func tryAuth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
// auth makes sure the user is logged in. // auth makes sure the user is logged in.
func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler { func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error { return func(r *fastglue.Request) error {
var ( var app = r.Context.(*App)
app = r.Context.(*App)
)
// Validate session and fetch user. // Validate session and fetch user.
userSession, err := app.auth.ValidateSession(r) userSession, err := app.auth.ValidateSession(r)

View File

@@ -9,6 +9,7 @@ import (
"github.com/zerodha/fastglue" "github.com/zerodha/fastglue"
) )
// handleGetRoles returns all roles
func handleGetRoles(r *fastglue.Request) error { func handleGetRoles(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
@@ -20,6 +21,7 @@ func handleGetRoles(r *fastglue.Request) error {
return r.SendEnvelope(agents) return r.SendEnvelope(agents)
} }
// handleGetRole returns a single role
func handleGetRole(r *fastglue.Request) error { func handleGetRole(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
@@ -32,18 +34,19 @@ func handleGetRole(r *fastglue.Request) error {
return r.SendEnvelope(role) return r.SendEnvelope(role)
} }
// handleDeleteRole deletes a role
func handleDeleteRole(r *fastglue.Request) error { func handleDeleteRole(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string)) id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
) )
err := app.role.Delete(id) if err := app.role.Delete(id); err != nil {
if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
return r.SendEnvelope(true) return r.SendEnvelope("Role deleted successfully")
} }
// handleCreateRole creates a new role
func handleCreateRole(r *fastglue.Request) error { func handleCreateRole(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
@@ -52,13 +55,13 @@ func handleCreateRole(r *fastglue.Request) error {
if err := r.Decode(&req, "json"); err != nil { if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
} }
err := app.role.Create(req) if err := app.role.Create(req); err != nil {
if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
return r.SendEnvelope(true) return r.SendEnvelope("Role created successfully")
} }
// handleUpdateRole updates a role
func handleUpdateRole(r *fastglue.Request) error { func handleUpdateRole(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
@@ -68,9 +71,8 @@ func handleUpdateRole(r *fastglue.Request) error {
if err := r.Decode(&req, "json"); err != nil { if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
} }
err := app.role.Update(id, req) if err := app.role.Update(id, req);err != nil {
if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
return r.SendEnvelope(true) return r.SendEnvelope("Role updated successfully")
} }

View File

@@ -20,7 +20,15 @@ func handleGetGeneralSettings(r *fastglue.Request) error {
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
return r.SendEnvelope(out) // Unmarshal to add the app.update to the settings.
var settings map[string]interface{}
if err := json.Unmarshal(out, &settings); err != nil {
app.lo.Error("error unmarshalling settings", "err", err)
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "Error fetching settings", nil))
}
// Add the app.update to the settings, adding `app` prefix to the key to match the settings structure in db.
settings["app.update"] = app.update
return r.SendEnvelope(settings)
} }
// handleUpdateGeneralSettings updates general settings. // handleUpdateGeneralSettings updates general settings.

98
cmd/updates.go Normal file
View File

@@ -0,0 +1,98 @@
// Copyright Kailash Nadh (https://github.com/knadh/listmonk)
// SPDX-License-Identifier: AGPL-3.0
// Adapted from listmonk for Libredesk.
package main
import (
"encoding/json"
"io"
"net/http"
"regexp"
"time"
"golang.org/x/mod/semver"
)
const updateCheckURL = "https://updates.libredesk.io/updates.json"
type AppUpdate struct {
Update struct {
ReleaseVersion string `json:"release_version"`
ReleaseDate string `json:"release_date"`
URL string `json:"url"`
Description string `json:"description"`
// This is computed and set locally based on the local version.
IsNew bool `json:"is_new"`
} `json:"update"`
Messages []struct {
Date string `json:"date"`
Title string `json:"title"`
Description string `json:"description"`
URL string `json:"url"`
Priority string `json:"priority"`
} `json:"messages"`
}
var reSemver = regexp.MustCompile(`-(.*)`)
// checkUpdates is a blocking function that checks for updates to the app
// at the given intervals. On detecting a new update (new semver), it
// sets the global update status that renders a prompt on the UI.
func checkUpdates(curVersion string, interval time.Duration, app *App) {
// Strip -* suffix.
curVersion = reSemver.ReplaceAllString(curVersion, "")
fnCheck := func() {
resp, err := http.Get(updateCheckURL)
if err != nil {
app.lo.Error("error checking for app updates", "err", err)
return
}
if resp.StatusCode != 200 {
app.lo.Error("non-ok status code checking for app updates", "status", resp.StatusCode)
return
}
b, err := io.ReadAll(resp.Body)
if err != nil {
app.lo.Error("error reading response body", "err", err)
return
}
resp.Body.Close()
var out AppUpdate
if err := json.Unmarshal(b, &out); err != nil {
app.lo.Error("error unmarshalling response body", "err", err)
return
}
// There is an update. Set it on the global app state.
if semver.IsValid(out.Update.ReleaseVersion) {
v := reSemver.ReplaceAllString(out.Update.ReleaseVersion, "")
if semver.Compare(v, curVersion) > 0 {
out.Update.IsNew = true
app.lo.Info("new update available", "version", out.Update.ReleaseVersion)
}
}
app.Lock()
app.update = &out
app.Unlock()
}
// Give a 15 minute buffer after app start in case the admin wants to disable
// update checks entirely and not make a request to upstream.
time.Sleep(time.Minute * 15)
fnCheck()
// Thereafter, check every $interval.
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
fnCheck()
}
}

148
cmd/upgrade.go Normal file
View File

@@ -0,0 +1,148 @@
// Copyright Kailash Nadh (https://github.com/knadh/listmonk)
// SPDX-License-Identifier: AGPL-3.0
// Adapted from listmonk for Libredesk.
package main
import (
"fmt"
"log"
"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"
"golang.org/x/mod/semver"
)
// migFunc represents a migration function for a particular version.
// fn (generally) executes database migrations and additionally
// takes the filesystem and config objects in case there are additional bits
// of logic to be performed before executing upgrades. fn is idempotent.
type migFunc struct {
version string
fn func(*sqlx.DB, stuffbin.FileSystem, *koanf.Koanf) error
}
// 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{
{"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.
func upgrade(db *sqlx.DB, fs stuffbin.FileSystem, prompt bool) {
if prompt {
var ok string
fmt.Printf("** IMPORTANT: Take a backup of the database before upgrading.\n")
fmt.Print("continue (y/n)? ")
if _, err := fmt.Scanf("%s", &ok); err != nil {
log.Fatalf("error reading value from terminal: %v", err)
}
if !strings.EqualFold(ok, "y") {
fmt.Println("upgrade cancelled")
return
}
}
_, toRun, err := getPendingMigrations(db)
if err != nil {
log.Fatalf("error checking migrations: %v", err)
}
// No migrations to run.
if len(toRun) == 0 {
log.Printf("no upgrades to run. Database is up to date.")
return
}
// Execute migrations in succession.
for _, m := range toRun {
log.Printf("running migration %s", m.version)
if err := m.fn(db, fs, ko); err != nil {
log.Fatalf("error running migration %s: %v", m.version, err)
}
// Record the migration version in the settings table. There was no
// settings table until v0.7.0, so ignore the no-table errors.
if err := recordMigrationVersion(m.version, db); err != nil {
if dbutil.IsTableNotExistError(err) {
continue
}
log.Fatalf("error recording migration version %s: %v", m.version, err)
}
}
log.Printf("upgrade complete")
}
// getPendingMigrations gets the pending migrations by comparing the last
// recorded migration in the DB against all migrations listed in `migrations`.
func getPendingMigrations(db *sqlx.DB) (string, []migFunc, error) {
lastVer, err := getLastMigrationVersion(db)
if err != nil {
return "", nil, err
}
// Iterate through the migration versions and get everything above the last
// upgraded semver.
var toRun []migFunc
for i, m := range migList {
if semver.Compare(m.version, lastVer) > 0 {
toRun = migList[i:]
break
}
}
return lastVer, toRun, nil
}
// getLastMigrationVersion returns the last migration semver recorded in the DB.
// If there isn't any, `v0.0.0` is returned.
func getLastMigrationVersion(db *sqlx.DB) (string, error) {
var v string
if err := db.Get(&v, `
SELECT COALESCE(
(SELECT value->>-1 FROM settings WHERE key='migrations'),
'v0.0.0')`); err != nil {
if dbutil.IsTableNotExistError(err) {
return "v0.0.0", nil
}
return v, err
}
return v, nil
}
// recordMigrationVersion inserts the given version (of DB migration) into the
// `migrations` array in the settings table.
func recordMigrationVersion(ver string, db *sqlx.DB) error {
_, err := db.Exec(fmt.Sprintf(`INSERT INTO settings (key, value)
VALUES('migrations', '["%s"]'::JSONB)
ON CONFLICT (key) DO UPDATE SET value = settings.value || EXCLUDED.value`, ver))
return err
}
// checkPendingUpgrade checks if the current database schema matches the expected binary version.
func checkPendingUpgrade(db *sqlx.DB) {
lastVer, toRun, err := getPendingMigrations(db)
if err != nil {
log.Fatalf("error checking migrations: %v", err)
}
// No migrations to run.
if len(toRun) == 0 {
return
}
var vers []string
for _, m := range toRun {
vers = append(vers, m.version)
}
log.Fatalf(`there are %d pending database upgrade(s): %v. The last upgrade was %s. Backup the database and run libredesk --upgrade`,
len(toRun), vers, lastVer)
}

View File

@@ -22,7 +22,7 @@ import (
) )
const ( const (
maxAvatarSizeMB = 5 maxAvatarSizeMB = 20
) )
// handleGetUsers returns all users. // handleGetUsers returns all users.
@@ -39,9 +39,7 @@ func handleGetUsers(r *fastglue.Request) error {
// handleGetUsersCompact returns all users in a compact format. // handleGetUsersCompact returns all users in a compact format.
func handleGetUsersCompact(r *fastglue.Request) error { func handleGetUsersCompact(r *fastglue.Request) error {
var ( var app = r.Context.(*App)
app = r.Context.(*App)
)
agents, err := app.user.GetAllCompact() agents, err := app.user.GetAllCompact()
if err != nil { if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "") return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
@@ -66,6 +64,19 @@ func handleGetUser(r *fastglue.Request) error {
return r.SendEnvelope(user) return r.SendEnvelope(user)
} }
// handleUpdateUserAvailability updates the current user availability.
func handleUpdateUserAvailability(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
status = string(r.RequestCtx.PostArgs().Peek("status"))
)
if err := app.user.UpdateAvailability(auser.ID, status); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("User availability updated successfully.")
}
// handleGetCurrentUserTeams returns the teams of a user. // handleGetCurrentUserTeams returns the teams of a user.
func handleGetCurrentUserTeams(r *fastglue.Request) error { func handleGetCurrentUserTeams(r *fastglue.Request) error {
var ( var (
@@ -228,7 +239,7 @@ func handleCreateUser(r *fastglue.Request) error {
Provider: notifier.ProviderEmail, Provider: notifier.ProviderEmail,
}); err != nil { }); err != nil {
app.lo.Error("error sending notification message", "error", err) app.lo.Error("error sending notification message", "error", err)
return r.SendEnvelope("User created successfully, but error sending welcome email.") return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "User created successfully, but could not send welcome email.", nil))
} }
} }
return r.SendEnvelope("User created successfully.") return r.SendEnvelope("User created successfully.")

View File

@@ -2,6 +2,7 @@
[app] [app]
log_level = "debug" log_level = "debug"
env = "dev" env = "dev"
check_updates = true
# HTTP server. # HTTP server.
[app.server] [app.server]
@@ -12,7 +13,7 @@ write_timeout = "5s"
max_body_size = 500000000 max_body_size = 500000000
keepalive_timeout = "10s" keepalive_timeout = "10s"
# File upload provider to use. # File upload provider to use, either `fs` or `s3`.
[upload] [upload]
provider = "fs" provider = "fs"
@@ -32,7 +33,7 @@ expiry = "6h"
# Postgres. # Postgres.
[db] [db]
# If using docker compose, use the service name as the host. # If using docker compose, use the service name as the host. e.g. db
host = "127.0.0.1" host = "127.0.0.1"
port = 5432 port = 5432
user = "postgres" user = "postgres"
@@ -45,7 +46,7 @@ max_lifetime = "300s"
# Redis. # Redis.
[redis] [redis]
# If using docker compose, use the service name as the host. # If using docker compose, use the service name as the host. e.g. redis:6379
address = "127.0.0.1:6379" address = "127.0.0.1:6379"
password = "" password = ""
db = 0 db = 0

View File

@@ -1,7 +1,7 @@
services: services:
# Libredesk app # Libredesk app
app: app:
image: libredesk:latest image: libredesk/libredesk:latest
container_name: libredesk_app container_name: libredesk_app
restart: unless-stopped restart: unless-stopped
ports: ports:

View File

@@ -0,0 +1,31 @@
# Developer Setup
Libredesk is a monorepo with a Go backend and a Vue.js frontend. The frontend uses Shadcn for UI components.
### Pre-requisites
- `go`
- `nodejs` (if you are working on the frontend) and `pnpm`
- Postgres database (>= 13)
### First time setup
Clone the repository:
```sh
git clone https://github.com/abhinavxd/libredesk.git
```
1. Copy `config.toml.sample` as `config.toml` and add your config.
2. Run `make` to build the libredesk binary. Once the binary is built, run `./libredesk --install` to run the DB setup and set the System user password.
### Running the Dev Environment
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.
---
# Production Build
Run `make` to build the Go binary, build the Javascript frontend, and embed the static assets producing a single self-contained binary, `libredesk`.

13
docs/docs/index.md Normal file
View File

@@ -0,0 +1,13 @@
# Introduction
Libredesk is an open source, self-hosted customer support desk. Single binary app.
<div style="border: 1px solid #ccc; padding: 1px; border-radius:5px; box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.1); background-color: #fff;">
<a href="https://libredesk.io">
<img src="https://hebbkx1anhila5yf.public.blob.vercel-storage.com/Screenshot_20250220_231723-VxuEQgEiFfI9xhzJDOvgMK0yJ0TwR3.png" alt="libredesk screenshot" style="display: block; margin: 0 auto;">
</a>
</div>
## Developers
Libredesk is a free and open source software licensed under AGPLv3. If you are interested in contributing, check out the [GitHub repository](https://github.com/abhinavxd/libredesk) and refer to the [developer setup](developer-setup.md). The backend is written in Go and the frontend is Vue js 3 with Shadcn for UI components.

48
docs/docs/installation.md Normal file
View File

@@ -0,0 +1,48 @@
# Installation
Libredesk is a single binary application that requires postgres and redis to run. You can install it using the binary or docker.
## Binary
1. Download the [latest release](https://github.com/abhinavxd/libredesk/releases) and extract the libredesk binary.
2. `./libredesk --install` to install the tables in the Postgres DB (⩾ 13) and set the System user password.
3. Run `./libredesk` and visit `http://localhost:9000` and login with the email `System` and the password you set during installation.
!!! Tip
To set the System user password during installation, set the environment variables:
`LIBREDESK_SYSTEM_USER_PASSWORD=xxxxxxxxxxx ./libredesk --install`
## Docker
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/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.
curl -LO https://github.com/abhinavxd/libredesk/raw/main/docker-compose.yml
curl -LO https://github.com/abhinavxd/libredesk/raw/main/config.sample.toml
# Copy the config.sample.toml to config.toml and edit it as needed.
cp config.sample.toml config.toml
# Run the services in the background.
docker compose up -d
# Setting System user password.
docker exec -it libredesk_app ./libredesk --set-system-user-password
```
Go to `http://localhost:9000` and login with the email `System` and the password you set using the `--set-system-user-password` command.
---
## Compiling from source
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`
3. `cd libredesk && make`. This will generate the `libredesk` binary.

18
docs/docs/upgrade.md Normal file
View File

@@ -0,0 +1,18 @@
# Upgrade
!!! Warning
Always take a backup of the Postgres database before upgrading Libredesk.
## Binary
- Stop running libredesk binary.
- Download the [latest release](https://github.com/abhinavxd/libredesk/releases) and extract the libredesk binary and overwrite the previous version.
- `./libredesk --upgrade` to upgrade an existing database schema. Upgrades are idempotent and running them multiple times have no side effects.
- Run `./libredesk` again.
## Docker
```shell
docker compose down app
docker compose pull
docker compose up app -d
```

34
docs/mkdocs.yml Normal file
View File

@@ -0,0 +1,34 @@
site_name: Libredesk Documentation
theme:
name: material
language: en
font:
text: Source Sans Pro
code: Roboto Mono
weights:
- 400
- 700
direction: ltr
palette:
primary: white
accent: red
features:
- navigation.indexes
- navigation.sections
- content.code.copy
extra:
search:
language: en
markdown_extensions:
- admonition
- codehilite
- toc:
permalink: true
nav:
- Introduction: index.md
- Getting Started:
- Installation: installation.md
- Upgrade: upgrade.md
- Developer Setup: developer-setup.md

View File

@@ -1,8 +0,0 @@
{
"recommendations": [
"Vue.volar",
"Vue.vscode-typescript-vue-plugin",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
]
}

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "libredesk", "name": "libredesk",
"version": "0.0.0", "version": "0.3.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -18,41 +18,29 @@
"@formkit/auto-animate": "^0.8.2", "@formkit/auto-animate": "^0.8.2",
"@internationalized/date": "^3.5.5", "@internationalized/date": "^3.5.5",
"@radix-icons/vue": "^1.0.0", "@radix-icons/vue": "^1.0.0",
"@tailwindcss/typography": "^0.5.10",
"@tanstack/vue-table": "^8.19.2", "@tanstack/vue-table": "^8.19.2",
"@tiptap/extension-image": "^2.5.9", "@tiptap/extension-image": "^2.5.9",
"@tiptap/extension-link": "^2.9.1", "@tiptap/extension-link": "^2.9.1",
"@tiptap/extension-ordered-list": "^2.4.0",
"@tiptap/extension-placeholder": "^2.4.0", "@tiptap/extension-placeholder": "^2.4.0",
"@tiptap/pm": "^2.4.0", "@tiptap/pm": "^2.4.0",
"@tiptap/starter-kit": "^2.4.0", "@tiptap/starter-kit": "^2.4.0",
"@tiptap/suggestion": "^2.4.0",
"@tiptap/vue-3": "^2.4.0", "@tiptap/vue-3": "^2.4.0",
"@unovis/ts": "^1.4.4", "@unovis/ts": "^1.4.4",
"@unovis/vue": "^1.4.4", "@unovis/vue": "^1.4.4",
"@vee-validate/zod": "^4.13.2", "@vee-validate/zod": "^4.13.2",
"@vue/reactivity": "^3.4.15",
"@vue/runtime-core": "^3.4.15",
"@vueup/vue-quill": "^1.2.0", "@vueup/vue-quill": "^1.2.0",
"@vueuse/core": "^12.4.0", "@vueuse/core": "^12.4.0",
"add": "^2.0.6",
"axios": "^1.7.9", "axios": "^1.7.9",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"codeflask": "^1.4.1", "codeflask": "^1.4.1",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"install": "^0.13.0",
"lucide-vue-next": "^0.378.0", "lucide-vue-next": "^0.378.0",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"npm": "^10.4.0",
"npx": "^10.2.2",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"qs": "^6.12.1", "qs": "^6.12.1",
"radix-vue": "latest", "radix-vue": "latest",
"shadcn-vue": "latest",
"tailwind-merge": "^2.3.0", "tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7",
"textarea": "^0.3.0",
"vee-validate": "^4.13.2", "vee-validate": "^4.13.2",
"vue": "^3.4.37", "vue": "^3.4.37",
"vue-i18n": "9", "vue-i18n": "9",
@@ -68,7 +56,7 @@
"@rushstack/eslint-patch": "^1.3.3", "@rushstack/eslint-patch": "^1.3.3",
"@vitejs/plugin-vue": "^5.0.3", "@vitejs/plugin-vue": "^5.0.3",
"@vue/eslint-config-prettier": "^8.0.0", "@vue/eslint-config-prettier": "^8.0.0",
"autoprefixer": "latest", "autoprefixer": "^10.4.20",
"cypress": "^13.6.3", "cypress": "^13.6.3",
"eslint": "^8.49.0", "eslint": "^8.49.0",
"eslint-plugin-cypress": "^2.15.1", "eslint-plugin-cypress": "^2.15.1",
@@ -78,6 +66,7 @@
"sass": "^1.70.0", "sass": "^1.70.0",
"start-server-and-test": "^2.0.3", "start-server-and-test": "^2.0.3",
"tailwindcss": "latest", "tailwindcss": "latest",
"tailwindcss-animate": "^1.0.7",
"vite": "^5.4.9" "vite": "^5.4.9"
}, },
"packageManager": "pnpm@9.15.3+sha512.1f79bc245a66eb0b07c5d4d83131240774642caaa86ef7d0434ab47c0d16f66b04e21e0c086eb61e62c77efc4d7f7ec071afad3796af64892fae66509173893a" "packageManager": "pnpm@9.15.3+sha512.1f79bc245a66eb0b07c5d4d83131240774642caaa86ef7d0434ab47c0d16f66b04e21e0c086eb61e62c77efc4d7f7ec071afad3796af64892fae66509173893a"

1640
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -48,7 +48,13 @@
@delete-view="deleteView" @delete-view="deleteView"
> >
<div class="flex flex-col h-screen"> <div class="flex flex-col h-screen">
<!-- Show app update only in admin routes -->
<AppUpdate v-if="route.path.startsWith('/admin')" />
<!-- Common header for all pages -->
<PageHeader /> <PageHeader />
<!-- Main content -->
<RouterView class="flex-grow" /> <RouterView class="flex-grow" />
</div> </div>
<ViewForm v-model:openDialog="openCreateViewForm" v-model:view="view" /> <ViewForm v-model:openDialog="openCreateViewForm" v-model:view="view" />
@@ -75,8 +81,10 @@ import { useTeamStore } from '@/stores/team'
import { useSlaStore } from '@/stores/sla' import { useSlaStore } from '@/stores/sla'
import { useMacroStore } from '@/stores/macro' import { useMacroStore } from '@/stores/macro'
import { useTagStore } from '@/stores/tag' import { useTagStore } from '@/stores/tag'
import { useIdleDetection } from '@/composables/useIdleDetection'
import PageHeader from './components/layout/PageHeader.vue' import PageHeader from './components/layout/PageHeader.vue'
import ViewForm from '@/features/view/ViewForm.vue' import ViewForm from '@/features/view/ViewForm.vue'
import AppUpdate from '@/components/update/AppUpdate.vue'
import api from '@/api' import api from '@/api'
import { toast as sooner } from 'vue-sonner' import { toast as sooner } from 'vue-sonner'
import Sidebar from '@/components/sidebar/Sidebar.vue' import Sidebar from '@/components/sidebar/Sidebar.vue'
@@ -111,6 +119,8 @@ const view = ref({})
const openCreateViewForm = ref(false) const openCreateViewForm = ref(false)
initWS() initWS()
useIdleDetection()
onMounted(() => { onMounted(() => {
initToaster() initToaster()
listenViewRefresh() listenViewRefresh()
@@ -119,8 +129,10 @@ onMounted(() => {
// initialize data stores // initialize data stores
const initStores = async () => { const initStores = async () => {
if (!userStore.userID) {
await userStore.getCurrentUser()
}
await Promise.allSettled([ await Promise.allSettled([
userStore.getCurrentUser(),
getUserViews(), getUserViews(),
conversationStore.fetchStatuses(), conversationStore.fetchStatuses(),
conversationStore.fetchPriorities(), conversationStore.fetchPriorities(),

View File

@@ -169,6 +169,7 @@ const updateCurrentUser = (data) =>
const deleteUserAvatar = () => http.delete('/api/v1/users/me/avatar') const deleteUserAvatar = () => http.delete('/api/v1/users/me/avatar')
const getCurrentUser = () => http.get('/api/v1/users/me') const getCurrentUser = () => http.get('/api/v1/users/me')
const getCurrentUserTeams = () => http.get('/api/v1/users/me/teams') const getCurrentUserTeams = () => http.get('/api/v1/users/me/teams')
const updateCurrentUserAvailability = (data) => http.put('/api/v1/users/me/availability', data)
const getTags = () => http.get('/api/v1/tags') const getTags = () => http.get('/api/v1/tags')
const upsertTags = (uuid, data) => http.post(`/api/v1/conversations/${uuid}/tags`, data) const upsertTags = (uuid, data) => http.post(`/api/v1/conversations/${uuid}/tags`, data)
const updateAssignee = (uuid, assignee_type, data) => http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}`, data) const updateAssignee = (uuid, assignee_type, data) => http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}`, data)
@@ -323,6 +324,7 @@ export default {
uploadMedia, uploadMedia,
updateAssigneeLastSeen, updateAssigneeLastSeen,
updateUser, updateUser,
updateCurrentUserAvailability,
updateAutomationRule, updateAutomationRule,
updateAutomationRuleWeights, updateAutomationRuleWeights,
updateAutomationRulesExecutionMode, updateAutomationRulesExecutionMode,

View File

@@ -312,3 +312,7 @@ a[data-active='false']:hover {
opacity: 1; opacity: 1;
} }
} }
[data-radix-popper-content-wrapper] {
z-index: 9999 !important;
}

View File

@@ -1,13 +1,23 @@
<template> <template>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger as-child> <DropdownMenuTrigger as-child>
<SidebarMenuButton size="lg" <SidebarMenuButton
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground p-0"> size="lg"
<Avatar class="h-8 w-8 rounded-lg"> class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground p-0"
>
<Avatar class="h-8 w-8 rounded-lg relative overflow-visible">
<AvatarImage :src="userStore.avatar" alt="Abhinav" /> <AvatarImage :src="userStore.avatar" alt="Abhinav" />
<AvatarFallback class="rounded-lg"> <AvatarFallback class="rounded-lg">
{{ userStore.getInitials }} {{ userStore.getInitials }}
</AvatarFallback> </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> </Avatar>
<div class="grid flex-1 text-left text-sm leading-tight"> <div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{{ userStore.getFullName }}</span> <span class="truncate font-semibold">{{ userStore.getFullName }}</span>
@@ -16,9 +26,12 @@
<ChevronsUpDown class="ml-auto size-4" /> <ChevronsUpDown class="ml-auto size-4" />
</SidebarMenuButton> </SidebarMenuButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent class="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg" side="bottom" <DropdownMenuContent
:side-offset="4"> class="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
<DropdownMenuLabel class="p-0 font-normal"> 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"> <div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar class="h-8 w-8 rounded-lg"> <Avatar class="h-8 w-8 rounded-lg">
<AvatarImage :src="userStore.avatar" alt="Abhinav" /> <AvatarImage :src="userStore.avatar" alt="Abhinav" />
@@ -31,6 +44,13 @@
<span class="truncate text-xs">{{ userStore.email }}</span> <span class="truncate text-xs">{{ userStore.email }}</span>
</div> </div>
</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> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuGroup> <DropdownMenuGroup>
@@ -58,21 +78,12 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { import { SidebarMenuButton } from '@/components/ui/sidebar'
SidebarMenuButton, import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
} from '@/components/ui/sidebar' import { Switch } from '@/components/ui/switch'
import { import { ChevronsUpDown, CircleUserRound, LogOut } from 'lucide-vue-next'
Avatar,
AvatarFallback,
AvatarImage,
} from '@/components/ui/avatar'
import {
ChevronsUpDown,
CircleUserRound,
LogOut,
} from 'lucide-vue-next'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
const userStore = useUserStore() const userStore = useUserStore()

View File

@@ -3,6 +3,7 @@ import { Primitive } from 'radix-vue'
import { buttonVariants } from '.' import { buttonVariants } from '.'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { DotLoader } from '@/components/ui/loader'
const props = defineProps({ const props = defineProps({
variant: { type: null, required: false }, variant: { type: null, required: false },
@@ -29,11 +30,7 @@ const computedClass = computed(() => {
:class="computedClass" :class="computedClass"
:disabled="isLoading || isDisabled" :disabled="isLoading || isDisabled"
> >
<span v-if="isLoading" class="dot-loader"> <DotLoader v-if="isLoading" />
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</span>
<slot v-else /> <slot v-else />
</Primitive> </Primitive>
</template> </template>

View File

@@ -1,94 +0,0 @@
<script setup>
import { VisDonut, VisSingleContainer } from '@unovis/vue'
import { Donut } from '@unovis/ts'
import { computed, ref } from 'vue'
import { useMounted } from '@vueuse/core'
import { ChartSingleTooltip, defaultColors } from '@/components/ui/chart'
import { cn } from '@/lib/utils'
const props = defineProps({
data: { type: Array, required: true },
colors: { type: Array, required: false },
index: { type: null, required: true },
margin: {
type: null,
required: false,
default: () => ({ top: 0, bottom: 0, left: 0, right: 0 })
},
showLegend: { type: Boolean, required: false, default: true },
showTooltip: { type: Boolean, required: false, default: true },
filterOpacity: { type: Number, required: false, default: 0.2 },
category: { type: String, required: true },
type: { type: String, required: false, default: 'donut' },
sortFunction: { type: Function, required: false, default: () => undefined },
valueFormatter: { type: Function, required: false, default: (tick) => `${tick}` },
customTooltip: { type: null, required: false }
})
const category = computed(() => props.category)
const index = computed(() => props.index)
const isMounted = useMounted()
const activeSegmentKey = ref()
const colors = computed(() =>
props.colors?.length
? props.colors
: defaultColors(props.data.filter((d) => d[props.category]).filter(Boolean).length)
)
const legendItems = computed(() =>
props.data.map((item, i) => ({
name: item[props.index],
color: colors.value[i],
inactive: false
}))
)
const totalValue = computed(() =>
props.data.reduce((prev, curr) => {
return prev + curr[props.category]
}, 0)
)
</script>
<template>
<div :class="cn('w-full h-48 flex flex-col items-end', $attrs.class ?? '')">
<VisSingleContainer
:style="{ height: isMounted ? '100%' : 'auto' }"
:margin="{ left: 20, right: 20 }"
:data="data"
>
<ChartSingleTooltip
:selector="Donut.selectors.segment"
:index="category"
:items="legendItems"
:value-formatter="valueFormatter"
:custom-tooltip="customTooltip"
/>
<VisDonut
:value="(d) => d[category]"
:sort-function="sortFunction"
:color="colors"
:arc-width="type === 'donut' ? 20 : 0"
:show-background="false"
:central-label="type === 'donut' ? valueFormatter(totalValue) : ''"
:events="{
[Donut.selectors.segment]: {
click: (d, ev, i, elements) => {
if (d?.data?.[index] === activeSegmentKey) {
activeSegmentKey = undefined
elements.forEach((el) => (el.style.opacity = '1'))
} else {
activeSegmentKey = d?.data?.[index]
elements.forEach((el) => (el.style.opacity = `${filterOpacity}`))
elements[i].style.opacity = '1'
}
}
}
}"
/>
<slot />
</VisSingleContainer>
</div>
</template>

View File

@@ -1 +0,0 @@
export { default as DonutChart } from './DonutChart.vue'

View File

@@ -1,9 +1,7 @@
<template> <template>
<div class="flex flex-col items-center justify-center text-gray-600 dark:text-gray-300">
<span class="dot-loader"> <span class="dot-loader">
<span class="dot"></span> <span class="dot"></span>
<span class="dot"></span> <span class="dot"></span>
<span class="dot"></span> <span class="dot"></span>
</span> </span>
</div>
</template> </template>

View File

@@ -1,43 +0,0 @@
<script setup>
import { computed } from 'vue'
import { SplitterResizeHandle, useForwardPropsEmits } from 'radix-vue'
import { DragHandleDots2Icon } from '@radix-icons/vue'
import { cn } from '@/lib/utils'
const props = defineProps({
id: { type: String, required: false },
hitAreaMargins: { type: Object, required: false },
tabindex: { type: Number, required: false },
disabled: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
withHandle: { type: Boolean, required: false }
})
const emits = defineEmits(['dragging'])
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SplitterResizeHandle
v-bind="forwarded"
:class="
cn(
'relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 [&[data-orientation=vertical]]:h-px [&[data-orientation=vertical]]:w-full [&[data-orientation=vertical]]:after:left-0 [&[data-orientation=vertical]]:after:h-1 [&[data-orientation=vertical]]:after:w-full [&[data-orientation=vertical]]:after:-translate-y-1/2 [&[data-orientation=vertical]]:after:translate-x-0 [&[data-orientation=vertical]>div]:rotate-90',
props.class
)
"
>
<template v-if="props.withHandle">
<div class="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<DragHandleDots2Icon class="h-2.5 w-2.5" />
</div>
</template>
</SplitterResizeHandle>
</template>

View File

@@ -1,33 +0,0 @@
<script setup>
import { computed } from 'vue'
import { SplitterGroup, useForwardPropsEmits } from 'radix-vue'
import { cn } from '@/lib/utils'
const props = defineProps({
id: { type: [String, null], required: false },
autoSaveId: { type: [String, null], required: false },
direction: { type: String, required: true },
keyboardResizeBy: { type: [Number, null], required: false },
storage: { type: Object, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false }
})
const emits = defineEmits(['layout'])
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SplitterGroup
v-bind="forwarded"
:class="cn('flex h-full w-full data-[panel-group-direction=vertical]:flex-col', props.class)"
>
<slot />
</SplitterGroup>
</template>

View File

@@ -1,3 +0,0 @@
export { default as ResizablePanelGroup } from './ResizablePanelGroup.vue'
export { default as ResizableHandle } from './ResizableHandle.vue'
export { SplitterPanel as ResizablePanel } from 'radix-vue'

View File

@@ -1,31 +0,0 @@
<script setup>
import { computed } from 'vue'
import { ScrollAreaCorner, ScrollAreaRoot, ScrollAreaViewport } from 'radix-vue'
import ScrollBar from './ScrollBar.vue'
import { cn } from '@/lib/utils'
const props = defineProps({
type: { type: String, required: false },
dir: { type: String, required: false },
scrollHideDelay: { type: Number, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false }
})
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<ScrollAreaRoot v-bind="delegatedProps" :class="cn('relative overflow-hidden', props.class)">
<ScrollAreaViewport class="h-full w-full rounded-[inherit]">
<slot />
</ScrollAreaViewport>
<ScrollBar />
<ScrollAreaCorner />
</ScrollAreaRoot>
</template>

View File

@@ -1,35 +0,0 @@
<script setup>
import { computed } from 'vue'
import { ScrollAreaScrollbar, ScrollAreaThumb } from 'radix-vue'
import { cn } from '@/lib/utils'
const props = defineProps({
orientation: { type: String, required: false, default: 'vertical' },
forceMount: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false }
})
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<ScrollAreaScrollbar
v-bind="delegatedProps"
:class="
cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-px',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-px',
props.class
)
"
>
<ScrollAreaThumb class="relative flex-1 rounded-full bg-border" />
</ScrollAreaScrollbar>
</template>

View File

@@ -1,2 +0,0 @@
export { default as ScrollArea } from './ScrollArea.vue'
export { default as ScrollBar } from './ScrollBar.vue'

View File

@@ -1,43 +0,0 @@
<script setup>
import { computed, provide } from 'vue'
import { ToggleGroupRoot, useForwardPropsEmits } from 'radix-vue'
import { cn } from '@/lib/utils'
const props = defineProps({
rovingFocus: { type: Boolean, required: false },
disabled: { type: Boolean, required: false },
orientation: { type: String, required: false },
dir: { type: String, required: false },
loop: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
type: { type: null, required: false },
modelValue: { type: null, required: false },
defaultValue: { type: null, required: false },
class: { type: null, required: false },
variant: { type: null, required: false },
size: { type: null, required: false }
})
const emits = defineEmits(['update:modelValue'])
provide('toggleGroup', {
variant: props.variant,
size: props.size
})
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ToggleGroupRoot
v-bind="forwarded"
:class="cn('flex items-center justify-center gap-1', props.class)"
>
<slot />
</ToggleGroupRoot>
</template>

View File

@@ -1,44 +0,0 @@
<script setup>
import { computed, inject } from 'vue'
import { ToggleGroupItem, useForwardProps } from 'radix-vue'
import { toggleVariants } from '@/components/ui/toggle'
import { cn } from '@/lib/utils'
const props = defineProps({
value: { type: String, required: true },
defaultValue: { type: Boolean, required: false },
pressed: { type: Boolean, required: false },
disabled: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
variant: { type: null, required: false },
size: { type: null, required: false }
})
const context = inject('toggleGroup')
const delegatedProps = computed(() => {
const { class: _, variant, size, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<ToggleGroupItem
v-bind="forwardedProps"
:class="
cn(
toggleVariants({
variant: context?.variant || variant,
size: context?.size || size
}),
props.class
)
"
>
<slot />
</ToggleGroupItem>
</template>

View File

@@ -1,2 +0,0 @@
export { default as ToggleGroup } from './ToggleGroup.vue'
export { default as ToggleGroupItem } from './ToggleGroupItem.vue'

View File

@@ -0,0 +1,25 @@
<template>
<div
v-if="appSettingsStore.settings['app.update']?.update?.is_new"
class="p-2 mb-2 border-b bg-secondary text-secondary-foreground"
>
A new update is available:
{{ appSettingsStore.settings['app.update'].update.release_version }} ({{
appSettingsStore.settings['app.update'].update.release_date
}})
<a
:href="appSettingsStore.settings['app.update'].update.url"
target="_blank"
nofollow
noreferrer
class="underline ml-2"
>
View details
</a>
</div>
</template>
<script setup>
import { useAppSettingsStore } from '@/stores/appSettings'
const appSettingsStore = useAppSettingsStore()
</script>

View 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)
})
}

View File

@@ -1,14 +1,16 @@
<template> <template>
<Dialog v-model:open="dialogOpen"> <Dialog v-model:open="dialogOpen">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger as-child>
as-child <Button
variant="ghost"
class="w-8 h-8 p-0"
v-if="!CONVERSATION_DEFAULT_STATUSES_LIST.includes(props.status.name)" v-if="!CONVERSATION_DEFAULT_STATUSES_LIST.includes(props.status.name)"
> >
<Button variant="ghost" class="w-8 h-8 p-0">
<span class="sr-only">Open menu</span> <span class="sr-only">Open menu</span>
<MoreHorizontal class="w-4 h-4" /> <MoreHorizontal class="w-4 h-4" />
</Button> </Button>
<div v-else class="w-8 h-8 p-0 invisible"></div>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<DialogTrigger as-child> <DialogTrigger as-child>

View File

@@ -124,7 +124,9 @@
<Loader2 v-if="isLoading" class="mr-2 h-4 w-4 animate-spin" /> <Loader2 v-if="isLoading" class="mr-2 h-4 w-4 animate-spin" />
{{ isLoading ? 'Loading...' : 'Load more' }} {{ isLoading ? 'Loading...' : 'Load more' }}
</Button> </Button>
<p v-else class="text-sm text-gray-500">All conversations loaded</p> <p class="text-sm text-gray-500" v-else-if="conversationStore.conversationsList.length > 10">
All conversations loaded
</p>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -39,7 +39,7 @@
<!-- Message preview and unread count --> <!-- Message preview and unread count -->
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<div class="text-sm text-gray-600 flex items-center gap-1.5 flex-1"> <div class="text-sm text-gray-600 flex items-center gap-1.5 flex-1 break-all">
<Reply <Reply
class="text-green-600 flex-shrink-0" class="text-green-600 flex-shrink-0"
size="15" size="15"

View File

@@ -7,11 +7,7 @@
class="flex items-center bg-white border border-gray-200 rounded shadow-sm transition-all duration-300 ease-in-out hover:shadow-md group px-2 gap-2" class="flex items-center bg-white border border-gray-200 rounded shadow-sm transition-all duration-300 ease-in-out hover:shadow-md group px-2 gap-2"
> >
<div class="flex items-center space-x-1 py-1"> <div class="flex items-center space-x-1 py-1">
<span v-if="attachment.loading" class="dot-loader"> <DotLoader v-if="attachment.loading"/>
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</span>
<PaperclipIcon v-else size="16" class="text-gray-500 group-hover:text-primary" /> <PaperclipIcon v-else size="16" class="text-gray-500 group-hover:text-primary" />
<Tooltip> <Tooltip>
@@ -48,6 +44,7 @@
import { computed } from 'vue' import { computed } from 'vue'
import { formatBytes } from '@/utils/file.js' import { formatBytes } from '@/utils/file.js'
import { X, Paperclip as PaperclipIcon } from 'lucide-vue-next' import { X, Paperclip as PaperclipIcon } from 'lucide-vue-next'
import { DotLoader } from '@/components/ui/loader'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
const props = defineProps({ const props = defineProps({

View File

@@ -1,5 +0,0 @@
<template>
hi
</template>
<script setup></script>

View File

@@ -6,7 +6,7 @@
collapsible collapsible
:default-value="['Actions', 'Information', 'Previous conversations']" :default-value="['Actions', 'Information', 'Previous conversations']"
> >
<AccordionItem value="Actions" class="border-0 mb-2 mb-2"> <AccordionItem value="Actions" class="border-0 mb-2">
<AccordionTrigger class="bg-muted px-4 py-3 text-sm font-medium rounded-lg mx-2"> <AccordionTrigger class="bg-muted px-4 py-3 text-sm font-medium rounded-lg mx-2">
Actions Actions
</AccordionTrigger> </AccordionTrigger>

View File

@@ -1,25 +1,36 @@
<template> <template>
<div class="flex flex-1 flex-col gap-x-5 box p-5 space-y-5 bg-white"> <div class="flex flex-1 flex-col gap-x-5 box p-5 space-y-5 bg-white">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<p class="text-2xl">{{ title }}</p> <p class="text-2xl flex items-center">{{ title }}</p>
<div class="bg-green-100/70 flex items-center space-x-2 px-1 rounded"> <div class="bg-green-100/70 flex items-center space-x-2 px-1 rounded">
<span class="blinking-dot"></span> <span class="blinking-dot"></span>
<p class="uppercase text-xs">Live</p> <p class="uppercase text-xs">Live</p>
</div> </div>
</div> </div>
<div class="flex justify-between pr-32"> <div class="flex justify-between pr-32">
<div v-for="(value, key) in counts" :key="key" class="flex flex-col items-center gap-y-2"> <div
v-for="(item, key) in filteredCounts"
:key="key"
class="flex flex-col items-center gap-y-2"
>
<span class="text-muted-foreground">{{ labels[key] }}</span> <span class="text-muted-foreground">{{ labels[key] }}</span>
<span class="text-2xl font-medium">{{ value }}</span> <span class="text-2xl font-medium">{{ item }}</span>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
defineProps({ import { computed } from 'vue'
const props = defineProps({
counts: { type: Object, required: true }, counts: { type: Object, required: true },
labels: { type: Object, required: true }, labels: { type: Object, required: true },
title: { type: String, required: true } title: { type: String, required: true }
}) })
// Filter out counts that don't have a label
const filteredCounts = computed(() => {
return Object.fromEntries(Object.entries(props.counts).filter(([key]) => props.labels[key]))
})
</script> </script>

View File

@@ -1,16 +0,0 @@
<template>
<div class="flex flex-col space-y-6" v-if="userStore.getFullName">
<div>
<span class="font-medium text-xl space-y-1">
<p class="font-semibold text-2xl">Hi, {{ userStore.getFullName }}</p>
<p class="text-muted-foreground text-lg">🌤 {{ format(new Date(), 'EEEE, MMMM d, HH:mm a') }}</p>
</span>
</div>
</div>
</template>
<script setup>
import { format } from 'date-fns'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
</script>

View File

@@ -1,6 +1,7 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import { createI18n } from 'vue-i18n' import { createI18n } from 'vue-i18n'
import { useAppSettingsStore } from './stores/appSettings'
import router from './router' import router from './router'
import mitt from 'mitt' import mitt from 'mitt'
import api from './api' import api from './api'
@@ -38,12 +39,16 @@ async function initApp () {
const i18n = createI18n(i18nConfig) const i18n = createI18n(i18nConfig)
const app = createApp(Root) const app = createApp(Root)
const pinia = createPinia() const pinia = createPinia()
app.use(pinia)
// Store app settings in Pinia
const settingsStore = useAppSettingsStore()
settingsStore.setSettings(settings)
// Add emitter to global properties. // Add emitter to global properties.
app.config.globalProperties.emitter = emitter app.config.globalProperties.emitter = emitter
app.use(router) app.use(router)
app.use(pinia)
app.use(i18n) app.use(i18n)
app.mount('#app') app.mount('#app')
} }

View File

@@ -0,0 +1,12 @@
import { defineStore } from 'pinia'
export const useAppSettingsStore = defineStore('settings', {
state: () => ({
settings: {}
}),
actions: {
setSettings (newSettings) {
this.settings = newSettings
}
}
})

View File

@@ -282,8 +282,10 @@ export const useConversationStore = defineStore('conversation', () => {
async function fetchMessages (uuid, fetchNextPage = false) { async function fetchMessages (uuid, fetchNextPage = false) {
// Messages are already cached? // Messages are already cached?
let hasMessages = messages.data.getAllPagesMessages(uuid) let hasMessages = messages.data.getAllPagesMessages(uuid)
if (hasMessages.length > 0 && !fetchNextPage) if (hasMessages.length > 0 && !fetchNextPage) {
markConversationAsRead(uuid)
return return
}
// Fetch messages from server. // Fetch messages from server.
messages.loading = true messages.loading = true
@@ -293,7 +295,6 @@ export const useConversationStore = defineStore('conversation', () => {
const response = await api.getConversationMessages(uuid, { page: page, page_size: MESSAGE_LIST_PAGE_SIZE }) const response = await api.getConversationMessages(uuid, { page: page, page_size: MESSAGE_LIST_PAGE_SIZE })
const result = response.data?.data || {} const result = response.data?.data || {}
const newMessages = result.results || [] const newMessages = result.results || []
// Mark conversation as read
markConversationAsRead(uuid) markConversationAsRead(uuid)
// Cache messages // Cache messages
messages.data.addMessages(uuid, newMessages, result.page, result.total_pages) messages.data.addMessages(uuid, newMessages, result.page, result.total_pages)

View File

@@ -15,14 +15,15 @@ export const useUserStore = defineStore('user', () => {
avatar_url: '', avatar_url: '',
email: '', email: '',
teams: [], teams: [],
permissions: [] permissions: [],
availability_status: 'offline'
}) })
const emitter = useEmitter() const emitter = useEmitter()
const userID = computed(() => user.value.id) const userID = computed(() => user.value.id)
const firstName = computed(() => user.value.first_name) const firstName = computed(() => user.value.first_name || '')
const lastName = computed(() => user.value.last_name) const lastName = computed(() => user.value.last_name || '')
const avatar = computed(() => user.value.avatar_url) const avatar = computed(() => user.value.avatar_url || '')
const permissions = computed(() => user.value.permissions || []) const permissions = computed(() => user.value.permissions || [])
const email = computed(() => user.value.email) const email = computed(() => user.value.email)
const teams = computed(() => user.value.teams || []) const teams = computed(() => user.value.teams || [])
@@ -71,6 +72,10 @@ export const useUserStore = defineStore('user', () => {
} }
} }
const setCurrentUser = (userData) => {
user.value = userData
}
const setAvatar = (avatarURL) => { const setAvatar = (avatarURL) => {
if (typeof avatarURL !== 'string') { if (typeof avatarURL !== 'string') {
console.warn('Avatar URL must be a string') console.warn('Avatar URL must be a string')
@@ -83,6 +88,16 @@ export const useUserStore = defineStore('user', () => {
user.value.avatar_url = '' user.value.avatar_url = ''
} }
const updateUserAvailability = async (status, isManual = true) => {
try {
const apiStatus = status === 'away' && isManual ? 'away_manual' : status
await api.updateCurrentUserAvailability({ status: apiStatus })
user.value.availability_status = apiStatus
} catch (error) {
if (error?.response?.status === 401) window.location.href = '/'
}
}
return { return {
user, user,
userID, userID,
@@ -96,9 +111,11 @@ export const useUserStore = defineStore('user', () => {
getInitials, getInitials,
hasAdminTabPermissions, hasAdminTabPermissions,
hasReportTabPermissions, hasReportTabPermissions,
setCurrentUser,
getCurrentUser, getCurrentUser,
clearAvatar, clearAvatar,
setAvatar, setAvatar,
updateUserAvailability,
can can
} }
}) })

View File

@@ -0,0 +1,7 @@
export function debounce (fn, delay) {
let timeout
return function (...args) {
clearTimeout(timeout)
timeout = setTimeout(() => fn(...args), delay)
}
}

View File

@@ -11,10 +11,7 @@
</div> </div>
</template> </template>
<template #help> <template #help>
<p> <p>General settings for your support desk like timezone, working hours, etc.</p>
Configure core helpdesk settings like helpdesk name, timezone, business hours, and more.
</p>
<p>These settings affect your entire helpdesk system.</p>
</template> </template>
</AdminPageWithHelp> </AdminPageWithHelp>
</template> </template>

View File

@@ -5,7 +5,6 @@
</template> </template>
<template #help> <template #help>
<p>Combine multiple conversation actions into single-click macros.</p> <p>Combine multiple conversation actions into single-click macros.</p>
<p>Create reusable action sets for common agent responses.</p>
</template> </template>
</AdminPageWithHelp> </AdminPageWithHelp>
</template> </template>

View File

@@ -5,7 +5,7 @@
</template> </template>
<template #help> <template #help>
<p>Configure single sign-on with one or multiple OpenID Connect providers.</p> <p>Configure single sign-on with one or more OpenID Connect providers.</p>
</template> </template>
</AdminPageWithHelp> </AdminPageWithHelp>
</template> </template>

View File

@@ -5,7 +5,7 @@
</template> </template>
<template #help> <template #help>
<p>Manage roles and their permissions.</p> <p>Manage roles and their permissions for fine-grained control over your support desk.</p>
</template> </template>
</AdminPageWithHelp> </AdminPageWithHelp>
</template> </template>

View File

@@ -34,7 +34,7 @@
</template> </template>
<template #help> <template #help>
<p>Add custom conversation statuses to extend default workflow.</p> <p>Create custom conversation statuses to extend default workflow.</p>
</template> </template>
</AdminPageWithHelp> </AdminPageWithHelp>
</template> </template>

View File

@@ -32,7 +32,7 @@
</template> </template>
<template #help> <template #help>
<p>Create and organize tags to categorize conversations.</p> <p>Tags help you categorize your conversations. Create or edit tags here.</p>
</template> </template>
</AdminPageWithHelp> </AdminPageWithHelp>
</template> </template>

View File

@@ -6,7 +6,7 @@
<template #help> <template #help>
<p>Configure team settings including working hours and SLA policies.</p> <p>Configure team settings including working hours and SLA policies.</p>
<p>Manage agent auto-assignment rules for team efficiency.</p> <p>Manage agent auto-assignment limits and more.</p>
</template> </template>
</AdminPageWithHelp> </AdminPageWithHelp>
</template> </template>

View File

@@ -35,7 +35,7 @@
<template #help> <template #help>
<p>Design templates for customer communications and responses.</p> <p>Design templates for customer communications and responses.</p>
<p>Configure internal team notification templates.</p> <p>Modify content for internal and external emails.</p>
</template> </template>
</AdminPageWithHelp> </AdminPageWithHelp>
</template> </template>

View File

@@ -1,5 +1,5 @@
<template> <template>
<ConversationPlaceholder v-if="route.name === 'inbox'" /> <ConversationPlaceholder v-if="['inbox', 'team-inbox', 'view-inbox'].includes(route.name)" />
<router-view /> <router-view />
</template> </template>

View File

@@ -138,12 +138,14 @@ import { Card, CardContent, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { useEmitter } from '@/composables/useEmitter' import { useEmitter } from '@/composables/useEmitter'
import { useUserStore } from '@/stores/user'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
const emitter = useEmitter() const emitter = useEmitter()
const errorMessage = ref('') const errorMessage = ref('')
const isLoading = ref(false) const isLoading = ref(false)
const router = useRouter() const router = useRouter()
const userStore = useUserStore()
const loginForm = ref({ const loginForm = ref({
email: '', email: '',
password: '' password: ''
@@ -207,7 +209,10 @@ const loginAction = () => {
email: loginForm.value.email, email: loginForm.value.email,
password: loginForm.value.password password: loginForm.value.password
}) })
.then(() => { .then((resp) => {
if (resp?.data?.data) {
userStore.setCurrentUser(resp.data.data)
}
router.push({ name: 'inboxes' }) router.push({ name: 'inboxes' })
}) })
.catch((error) => { .catch((error) => {

View File

@@ -1,6 +1,7 @@
<template> <template>
<div class="overflow-y-auto">
<div <div
class="overflow-y-auto p-4 pr-36" class="p-4 w-[calc(100%-3rem)]"
:class="{ 'opacity-50 transition-opacity duration-300': isLoading }" :class="{ 'opacity-50 transition-opacity duration-300': isLoading }"
> >
<Spinner v-if="isLoading" /> <Spinner v-if="isLoading" />
@@ -13,8 +14,8 @@
<Card <Card
class="w-8/12" class="w-8/12"
title="Agent status" title="Agent status"
:counts="sampleAgentStatusCounts" :counts="agentStatusCounts"
:labels="sampleAgentStatusLabels" :labels="agentStatusLabels"
/> />
</div> </div>
<div class="rounded-lg box w-full p-5 bg-white"> <div class="rounded-lg box w-full p-5 bg-white">
@@ -25,6 +26,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>
<script setup> <script setup>
@@ -52,18 +54,18 @@ const agentCountCardsLabels = {
pending: 'Pending' pending: 'Pending'
} }
// TODO: Build agent status feature. const agentStatusLabels = {
const sampleAgentStatusLabels = { agents_online: 'Online',
online: 'Online', agents_offline: 'Offline',
offline: 'Offline', agents_away: 'Away'
away: 'Away'
}
const sampleAgentStatusCounts = {
online: 5,
offline: 2,
away: 1
} }
const agentStatusCounts = ref({
agents_online: 0,
agents_offline: 0,
agents_away: 0
})
onMounted(() => { onMounted(() => {
getDashboardData() getDashboardData()
startRealtimeUpdates() startRealtimeUpdates()
@@ -96,6 +98,11 @@ const getCardStats = async () => {
.getOverviewCounts() .getOverviewCounts()
.then((resp) => { .then((resp) => {
cardCounts.value = resp.data.data cardCounts.value = resp.data.data
agentStatusCounts.value = {
agents_online: cardCounts.value.agents_online,
agents_offline: cardCounts.value.agents_offline,
agents_away: cardCounts.value.agents_away
}
}) })
.catch((error) => { .catch((error) => {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {

View File

@@ -121,8 +121,8 @@ export class WebSocketClient {
if (this.socket?.readyState === WebSocket.OPEN) { if (this.socket?.readyState === WebSocket.OPEN) {
try { try {
this.socket.send('ping') this.socket.send('ping')
if (Date.now() - this.lastPong > 10000) { if (Date.now() - this.lastPong > 60000) {
console.warn('No pong received in 10 seconds, closing connection') console.warn('No pong received in 60 seconds, closing connection')
this.socket.close() this.socket.close()
} }
} catch (e) { } catch (e) {

View File

@@ -1,10 +1,16 @@
import { fileURLToPath, URL } from 'node:url' import { fileURLToPath, URL } from 'node:url'
import autoprefixer from 'autoprefixer'
import tailwind from 'tailwindcss'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
css: {
postcss: {
plugins: [tailwind(), autoprefixer()],
},
},
server: { server: {
port: 8000, port: 8000,
proxy: { proxy: {

1
go.mod
View File

@@ -35,6 +35,7 @@ require (
github.com/zerodha/simplesessions/stores/redis/v3 v3.0.0 github.com/zerodha/simplesessions/stores/redis/v3 v3.0.0
github.com/zerodha/simplesessions/v3 v3.0.0 github.com/zerodha/simplesessions/v3 v3.0.0
golang.org/x/crypto v0.31.0 golang.org/x/crypto v0.31.0
golang.org/x/mod v0.17.0
golang.org/x/oauth2 v0.21.0 golang.org/x/oauth2 v0.21.0
) )

2
go.sum
View File

@@ -187,6 +187,8 @@ golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=

View File

@@ -234,7 +234,10 @@ SELECT json_build_object(
'open', COUNT(*), 'open', COUNT(*),
'awaiting_response', COUNT(CASE WHEN c.waiting_since IS NOT NULL THEN 1 END), 'awaiting_response', COUNT(CASE WHEN c.waiting_since IS NOT NULL THEN 1 END),
'unassigned', COUNT(CASE WHEN c.assigned_user_id IS NULL THEN 1 END), 'unassigned', COUNT(CASE WHEN c.assigned_user_id IS NULL THEN 1 END),
'pending', COUNT(CASE WHEN c.first_reply_at IS NOT NULL THEN 1 END) 'pending', COUNT(CASE WHEN c.first_reply_at IS NOT NULL THEN 1 END),
'agents_online', (SELECT COUNT(*) FROM users WHERE availability_status = 'online' AND type = 'agent' AND deleted_at is null),
'agents_away', (SELECT COUNT(*) FROM users WHERE availability_status in ('away', 'away_manual') AND type = 'agent' AND deleted_at is null),
'agents_offline', (SELECT COUNT(*) FROM users WHERE availability_status = 'offline' AND type = 'agent' AND deleted_at is null)
) )
FROM conversations c FROM conversations c
INNER JOIN conversation_statuses s ON c.status_id = s.id INNER JOIN conversation_statuses s ON c.status_id = s.id

View File

@@ -23,3 +23,14 @@ func IsUniqueViolationError(err error) bool {
} }
return false return false
} }
// IsTableNotExistError checks if the given error is a PostgreSQL table does not exist error (error code 42P01)
func IsTableNotExistError(err error) bool {
if err == nil {
return false
}
if pqErr, ok := err.(*pq.Error); ok {
return pqErr.Code == "42P01"
}
return false
}

View 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
}

View File

@@ -115,6 +115,15 @@ func (u *Manager) Update(id int, r models.Role) error {
return err return err
} }
// Disallow updating `Admin` role, as the main System login requires it.
role, err := u.Get(id)
if err != nil {
return envelope.NewError(envelope.GeneralError, "Error fetching role", nil)
}
if role.Name == models.RoleAdmin {
return envelope.NewError(envelope.InputError, "Admin role cannot be updated, Please create a new role", nil)
}
if _, err := u.q.Update.Exec(id, r.Name, r.Description, pq.Array(r.Permissions)); err != nil { if _, err := u.q.Update.Exec(id, r.Name, r.Description, pq.Array(r.Permissions)); err != nil {
u.lo.Error("error updating role", "error", err) u.lo.Error("error updating role", "error", err)
return envelope.NewError(envelope.GeneralError, "Error updating role", nil) return envelope.NewError(envelope.GeneralError, "Error updating role", nil)

View File

@@ -8,6 +8,13 @@ import (
"github.com/volatiletech/null/v9" "github.com/volatiletech/null/v9"
) )
var (
Online = "online"
Offline = "offline"
Away = "away"
AwayManual = "away_manual"
)
type User struct { type User struct {
ID int `db:"id" json:"id,omitempty"` ID int `db:"id" json:"id,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"` CreatedAt time.Time `db:"created_at" json:"created_at"`
@@ -16,6 +23,7 @@ type User struct {
LastName string `db:"last_name" json:"last_name"` LastName string `db:"last_name" json:"last_name"`
Email null.String `db:"email" json:"email,omitempty"` Email null.String `db:"email" json:"email,omitempty"`
Type string `db:"type" json:"type"` Type string `db:"type" json:"type"`
AvailabilityStatus string `db:"availability_status" json:"availability_status"`
PhoneNumber null.String `db:"phone_number" json:"phone_number,omitempty"` PhoneNumber null.String `db:"phone_number" json:"phone_number,omitempty"`
AvatarURL null.String `db:"avatar_url" json:"avatar_url"` AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
Enabled bool `db:"enabled" json:"enabled"` Enabled bool `db:"enabled" json:"enabled"`

View File

@@ -20,27 +20,18 @@ SELECT email
FROM users FROM users
WHERE id = $1 AND deleted_at IS NULL AND type = 'agent'; WHERE id = $1 AND deleted_at IS NULL AND type = 'agent';
-- name: get-user-by-email
SELECT u.id, u.email, u.password, u.avatar_url, u.first_name, u.last_name, u.enabled,
array_agg(DISTINCT r.name) as roles,
array_agg(DISTINCT p) as permissions
FROM users u
JOIN user_roles ur ON ur.user_id = u.id
JOIN roles r ON r.id = ur.role_id,
unnest(r.permissions) p
WHERE u.email = $1 AND u.deleted_at IS NULL AND u.type = 'agent'
GROUP BY u.id;
-- name: get-user -- name: get-user
SELECT SELECT
u.id, u.id,
u.email,
u.password,
u.created_at, u.created_at,
u.updated_at, u.updated_at,
u.enabled, u.enabled,
u.email,
u.avatar_url, u.avatar_url,
u.first_name, u.first_name,
u.last_name, u.last_name,
u.availability_status,
array_agg(DISTINCT r.name) as roles, array_agg(DISTINCT r.name) as roles,
COALESCE( COALESCE(
(SELECT json_agg(json_build_object('id', t.id, 'name', t.name, 'emoji', t.emoji)) (SELECT json_agg(json_build_object('id', t.id, 'name', t.name, 'emoji', t.emoji))
@@ -54,7 +45,7 @@ FROM users u
LEFT JOIN user_roles ur ON ur.user_id = u.id LEFT JOIN user_roles ur ON ur.user_id = u.id
LEFT JOIN roles r ON r.id = ur.role_id, LEFT JOIN roles r ON r.id = ur.role_id,
unnest(r.permissions) p unnest(r.permissions) p
WHERE u.id = $1 AND u.deleted_at IS NULL AND u.type = 'agent' WHERE (u.id = $1 OR u.email = $2) AND u.deleted_at IS NULL AND u.type = 'agent'
GROUP BY u.id; GROUP BY u.id;
-- name: set-user-password -- name: set-user-password
@@ -92,6 +83,22 @@ UPDATE users
SET avatar_url = $2, updated_at = now() SET avatar_url = $2, updated_at = now()
WHERE id = $1 AND type = 'agent'; WHERE id = $1 AND type = 'agent';
-- name: update-availability
UPDATE users
SET availability_status = $2
WHERE id = $1;
-- name: update-last-active-at
UPDATE users
SET last_active_at = now(),
availability_status = CASE WHEN availability_status = 'offline' THEN 'online' ELSE availability_status END
WHERE id = $1;
-- name: update-inactive-offline
UPDATE users
SET availability_status = 'offline'
WHERE last_active_at < now() - interval '5 minutes' and availability_status != 'offline';
-- name: get-permissions -- name: get-permissions
SELECT DISTINCT unnest(r.permissions) SELECT DISTINCT unnest(r.permissions)
FROM users u FROM users u

View File

@@ -10,6 +10,7 @@ import (
"os" "os"
"regexp" "regexp"
"strings" "strings"
"time"
"log" "log"
@@ -28,8 +29,8 @@ import (
const ( const (
systemUserEmail = "System" systemUserEmail = "System"
minSystemUserPassword = 8 minSystemUserPassword = 10
maxSystemUserPassword = 50 maxSystemUserPassword = 72
UserTypeAgent = "agent" UserTypeAgent = "agent"
UserTypeContact = "contact" UserTypeContact = "contact"
) )
@@ -42,7 +43,7 @@ var (
// GenerateFromPassword is too long (i.e. > 72 bytes). // GenerateFromPassword is too long (i.e. > 72 bytes).
ErrPasswordTooLong = errors.New("password length exceeds 72 bytes") ErrPasswordTooLong = errors.New("password length exceeds 72 bytes")
SystemUserPasswordHint = fmt.Sprintf("Password must be %d-%d characters long and contain at least one uppercase letter and one number", minSystemUserPassword, maxSystemUserPassword) SystemUserPasswordHint = fmt.Sprintf("Password must be %d-%d characters long and contain at least one uppercase letter, one lowercase letter, one number, and one special character.", minSystemUserPassword, maxSystemUserPassword)
) )
// Manager handles user-related operations. // Manager handles user-related operations.
@@ -61,13 +62,15 @@ type Opts struct {
// queries contains prepared SQL queries. // queries contains prepared SQL queries.
type queries struct { type queries struct {
GetUsers *sqlx.Stmt `query:"get-users"` GetUsers *sqlx.Stmt `query:"get-users"`
GetUserCompact *sqlx.Stmt `query:"get-users-compact"` GetUsersCompact *sqlx.Stmt `query:"get-users-compact"`
GetUser *sqlx.Stmt `query:"get-user"` GetUser *sqlx.Stmt `query:"get-user"`
GetEmail *sqlx.Stmt `query:"get-email"` GetEmail *sqlx.Stmt `query:"get-email"`
GetPermissions *sqlx.Stmt `query:"get-permissions"` GetPermissions *sqlx.Stmt `query:"get-permissions"`
GetUserByEmail *sqlx.Stmt `query:"get-user-by-email"`
UpdateUser *sqlx.Stmt `query:"update-user"` UpdateUser *sqlx.Stmt `query:"update-user"`
UpdateAvatar *sqlx.Stmt `query:"update-avatar"` UpdateAvatar *sqlx.Stmt `query:"update-avatar"`
UpdateAvailability *sqlx.Stmt `query:"update-availability"`
UpdateLastActiveAt *sqlx.Stmt `query:"update-last-active-at"`
UpdateInactiveOffline *sqlx.Stmt `query:"update-inactive-offline"`
SoftDeleteUser *sqlx.Stmt `query:"soft-delete-user"` SoftDeleteUser *sqlx.Stmt `query:"soft-delete-user"`
SetUserPassword *sqlx.Stmt `query:"set-user-password"` SetUserPassword *sqlx.Stmt `query:"set-user-password"`
SetResetPasswordToken *sqlx.Stmt `query:"set-reset-password-token"` SetResetPasswordToken *sqlx.Stmt `query:"set-reset-password-token"`
@@ -89,22 +92,19 @@ func New(i18n *i18n.I18n, opts Opts) (*Manager, error) {
}, nil }, nil
} }
// VerifyPassword authenticates a user by email and password. // VerifyPassword authenticates an user by email and password.
func (u *Manager) VerifyPassword(email string, password []byte) (models.User, error) { func (u *Manager) VerifyPassword(email string, password []byte) (models.User, error) {
var user models.User var user models.User
if err := u.q.GetUser.Get(&user, 0, email); err != nil {
if err := u.q.GetUserByEmail.Get(&user, email); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return user, envelope.NewError(envelope.InputError, u.i18n.T("user.invalidEmailPassword"), nil) return user, envelope.NewError(envelope.InputError, u.i18n.T("user.invalidEmailPassword"), nil)
} }
u.lo.Error("error fetching user from db", "error", err) u.lo.Error("error fetching user from db", "error", err)
return user, envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorFetching", "name", "{globals.entities.user}"), nil) return user, envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorFetching", "name", "{globals.entities.user}"), nil)
} }
if err := u.verifyPassword(password, user.Password); err != nil { if err := u.verifyPassword(password, user.Password); err != nil {
return user, envelope.NewError(envelope.InputError, u.i18n.T("user.invalidEmailPassword"), nil) return user, envelope.NewError(envelope.InputError, u.i18n.T("user.invalidEmailPassword"), nil)
} }
return user, nil return user, nil
} }
@@ -125,7 +125,7 @@ func (u *Manager) GetAll() ([]models.User, error) {
// GetAllCompact returns a compact list of users with limited fields. // GetAllCompact returns a compact list of users with limited fields.
func (u *Manager) GetAllCompact() ([]models.User, error) { func (u *Manager) GetAllCompact() ([]models.User, error) {
var users = make([]models.User, 0) var users = make([]models.User, 0)
if err := u.q.GetUserCompact.Select(&users); err != nil { if err := u.q.GetUsersCompact.Select(&users); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return users, nil return users, nil
} }
@@ -154,10 +154,10 @@ func (u *Manager) CreateAgent(user *models.User) error {
return nil return nil
} }
// Get retrieves a user by ID. // Get retrieves an user by ID.
func (u *Manager) Get(id int) (models.User, error) { func (u *Manager) Get(id int) (models.User, error) {
var user models.User var user models.User
if err := u.q.GetUser.Get(&user, id); err != nil { if err := u.q.GetUser.Get(&user, id, ""); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
u.lo.Error("user not found", "id", id, "error", err) u.lo.Error("user not found", "id", id, "error", err)
return user, envelope.NewError(envelope.GeneralError, "User not found", nil) return user, envelope.NewError(envelope.GeneralError, "User not found", nil)
@@ -168,10 +168,10 @@ func (u *Manager) Get(id int) (models.User, error) {
return user, nil return user, nil
} }
// GetByEmail retrieves a user by email // GetByEmail retrieves an user by email
func (u *Manager) GetByEmail(email string) (models.User, error) { func (u *Manager) GetByEmail(email string) (models.User, error) {
var user models.User var user models.User
if err := u.q.GetUserByEmail.Get(&user, email); err != nil { if err := u.q.GetUser.Get(&user, 0, email); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return user, envelope.NewError(envelope.GeneralError, "User not found", nil) return user, envelope.NewError(envelope.GeneralError, "User not found", nil)
} }
@@ -195,10 +195,10 @@ func (u *Manager) UpdateAvatar(id int, avatar string) error {
return nil return nil
} }
// Update updates a user. // Update updates an user.
func (u *Manager) Update(id int, user models.User) error { func (u *Manager) Update(id int, user models.User) error {
var ( var (
hashedPassword interface{} hashedPassword any
err error err error
) )
@@ -221,7 +221,7 @@ func (u *Manager) Update(id int, user models.User) error {
return nil return nil
} }
// SoftDelete soft deletes a user. // SoftDelete soft deletes an user.
func (u *Manager) SoftDelete(id int) error { func (u *Manager) SoftDelete(id int) error {
// Disallow if user is system user. // Disallow if user is system user.
systemUser, err := u.GetSystemUser() systemUser, err := u.GetSystemUser()
@@ -239,7 +239,7 @@ func (u *Manager) SoftDelete(id int) error {
return nil return nil
} }
// GetEmail retrieves the email of a user by ID. // GetEmail retrieves the email of an user by ID.
func (u *Manager) GetEmail(id int) (string, error) { func (u *Manager) GetEmail(id int) (string, error) {
var email string var email string
if err := u.q.GetEmail.Get(&email, id); err != nil { if err := u.q.GetEmail.Get(&email, id); err != nil {
@@ -252,7 +252,7 @@ func (u *Manager) GetEmail(id int) (string, error) {
return email, nil return email, nil
} }
// SetResetPasswordToken sets a reset password token for a user and returns the token. // SetResetPasswordToken sets a reset password token for an user and returns the token.
func (u *Manager) SetResetPasswordToken(id int) (string, error) { func (u *Manager) SetResetPasswordToken(id int) (string, error) {
token, err := stringutil.RandomAlphanumeric(32) token, err := stringutil.RandomAlphanumeric(32)
if err != nil { if err != nil {
@@ -266,7 +266,7 @@ func (u *Manager) SetResetPasswordToken(id int) (string, error) {
return token, nil return token, nil
} }
// ResetPassword sets a new password for a user. // ResetPassword sets a new password for an user.
func (u *Manager) ResetPassword(token, password string) error { func (u *Manager) ResetPassword(token, password string) error {
if !u.isStrongPassword(password) { if !u.isStrongPassword(password) {
return envelope.NewError(envelope.InputError, "Password is not strong enough, "+SystemUserPasswordHint, nil) return envelope.NewError(envelope.InputError, "Password is not strong enough, "+SystemUserPasswordHint, nil)
@@ -284,7 +284,7 @@ func (u *Manager) ResetPassword(token, password string) error {
return nil return nil
} }
// GetPermissions retrieves the permissions of a user by ID. // GetPermissions retrieves the permissions of an user by ID.
func (u *Manager) GetPermissions(id int) ([]string, error) { func (u *Manager) GetPermissions(id int) ([]string, error) {
var permissions []string var permissions []string
if err := u.q.GetPermissions.Select(&permissions, id); err != nil { if err := u.q.GetPermissions.Select(&permissions, id); err != nil {
@@ -294,6 +294,52 @@ func (u *Manager) GetPermissions(id int) ([]string, error) {
return permissions, nil return permissions, nil
} }
// UpdateAvailability updates the availability status of an user.
func (u *Manager) UpdateAvailability(id int, status string) error {
if _, err := u.q.UpdateAvailability.Exec(id, status); err != nil {
u.lo.Error("error updating user availability", "error", err)
return envelope.NewError(envelope.GeneralError, "Error updating user availability", nil)
}
return nil
}
// UpdateLastActive updates the last active timestamp of an user.
func (u *Manager) UpdateLastActive(id int) error {
if _, err := u.q.UpdateLastActiveAt.Exec(id); err != nil {
u.lo.Error("error updating user last active at", "error", err)
return envelope.NewError(envelope.GeneralError, "Error updating user last active at", nil)
}
return nil
}
// MonitorAgentAvailability continuously checks for user activity and sets them offline if inactive for more than 5 minutes.
func (u *Manager) MonitorAgentAvailability(ctx context.Context) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
u.markInactiveAgentsOffline()
case <-ctx.Done():
return
}
}
}
// markInactiveAgentsOffline sets agents offline if they have been inactive for more than 5 minutes.
func (u *Manager) markInactiveAgentsOffline() {
u.lo.Debug("marking inactive agents offline")
if res, err := u.q.UpdateInactiveOffline.Exec(); err != nil {
u.lo.Error("error setting users offline", "error", err)
} else {
rows, _ := res.RowsAffected()
if rows > 0 {
u.lo.Info("set inactive users offline", "count", rows)
}
}
u.lo.Debug("marked inactive agents offline")
}
// verifyPassword compares the provided password with the stored password hash. // verifyPassword compares the provided password with the stored password hash.
func (u *Manager) verifyPassword(pwd []byte, pwdHash string) error { func (u *Manager) verifyPassword(pwd []byte, pwdHash string) error {
if err := bcrypt.CompareHashAndPassword([]byte(pwdHash), pwd); err != nil { if err := bcrypt.CompareHashAndPassword([]byte(pwdHash), pwd); err != nil {
@@ -335,7 +381,7 @@ func ChangeSystemUserPassword(ctx context.Context, db *sqlx.DB) error {
if err := updateSystemUserPassword(db, hashedPassword); err != nil { if err := updateSystemUserPassword(db, hashedPassword); err != nil {
return fmt.Errorf("error updating system user password: %v", err) return fmt.Errorf("error updating system user password: %v", err)
} }
fmt.Println("password updated successfully.") fmt.Println("password updated successfully. Login with email 'System' and the new password.")
return nil return nil
} }
@@ -382,8 +428,11 @@ func IsStrongSystemUserPassword(password string) bool {
return false return false
} }
hasUppercase := regexp.MustCompile(`[A-Z]`).MatchString(password) hasUppercase := regexp.MustCompile(`[A-Z]`).MatchString(password)
hasLowercase := regexp.MustCompile(`[a-z]`).MatchString(password)
hasNumber := regexp.MustCompile(`[0-9]`).MatchString(password) hasNumber := regexp.MustCompile(`[0-9]`).MatchString(password)
return hasUppercase && hasNumber // Matches special characters
hasSpecial := regexp.MustCompile(`[\W_]`).MatchString(password)
return hasUppercase && hasLowercase && hasNumber && hasSpecial
} }
// promptAndHashPassword handles password input and validation, and returns the hashed password. // promptAndHashPassword handles password input and validation, and returns the hashed password.

View File

@@ -94,7 +94,9 @@ func (c *Client) Listen() {
// processIncomingMessage processes incoming messages from the client. // processIncomingMessage processes incoming messages from the client.
func (c *Client) processIncomingMessage(data []byte) { func (c *Client) processIncomingMessage(data []byte) {
// Handle ping messages, and update last active time for user.
if string(data) == "ping" { if string(data) == "ping" {
c.Hub.userStore.UpdateLastActive(c.ID)
c.SendMessage([]byte("pong"), websocket.TextMessage) c.SendMessage([]byte("pong"), websocket.TextMessage)
return return
} }

View File

@@ -13,13 +13,20 @@ type Hub struct {
// Client ID to WS Client map, user can connect from multiple devices and each device will have a separate client. // Client ID to WS Client map, user can connect from multiple devices and each device will have a separate client.
clients map[int][]*Client clients map[int][]*Client
clientsMutex sync.Mutex clientsMutex sync.Mutex
userStore userStore
}
type userStore interface {
UpdateLastActive(userID int) error
} }
// NewHub creates a new websocket hub. // NewHub creates a new websocket hub.
func NewHub() *Hub { func NewHub(userStore userStore) *Hub {
return &Hub{ return &Hub{
clients: make(map[int][]*Client, 10000), clients: make(map[int][]*Client, 10000),
clientsMutex: sync.Mutex{}, clientsMutex: sync.Mutex{},
userStore: userStore,
} }
} }

View File

@@ -13,6 +13,7 @@ DROP TYPE IF EXISTS "automation_execution_mode" CASCADE; CREATE TYPE "automation
DROP TYPE IF EXISTS "macro_visibility" CASCADE; CREATE TYPE "macro_visibility" AS ENUM ('all', 'team', 'user'); DROP TYPE IF EXISTS "macro_visibility" CASCADE; CREATE TYPE "macro_visibility" AS ENUM ('all', 'team', 'user');
DROP TYPE IF EXISTS "media_disposition" CASCADE; CREATE TYPE "media_disposition" AS ENUM ('inline', 'attachment'); DROP TYPE IF EXISTS "media_disposition" CASCADE; CREATE TYPE "media_disposition" AS ENUM ('inline', 'attachment');
DROP TYPE IF EXISTS "media_store" CASCADE; CREATE TYPE "media_store" AS ENUM ('s3', 'fs'); DROP TYPE IF EXISTS "media_store" CASCADE; CREATE TYPE "media_store" AS ENUM ('s3', 'fs');
DROP TYPE IF EXISTS "user_availability_status" CASCADE; CREATE TYPE "user_availability_status" AS ENUM ('online', 'away', 'away_manual', 'offline');
-- Sequence to generate reference number for conversations. -- Sequence to generate reference number for conversations.
DROP SEQUENCE IF EXISTS conversation_reference_number_sequence; CREATE SEQUENCE conversation_reference_number_sequence START 100; DROP SEQUENCE IF EXISTS conversation_reference_number_sequence; CREATE SEQUENCE conversation_reference_number_sequence START 100;
@@ -118,6 +119,8 @@ CREATE TABLE users (
custom_attributes JSONB DEFAULT '{}'::jsonb NOT NULL, custom_attributes JSONB DEFAULT '{}'::jsonb NOT NULL,
reset_password_token TEXT NULL, reset_password_token TEXT NULL,
reset_password_token_expiry TIMESTAMPTZ NULL, reset_password_token_expiry TIMESTAMPTZ NULL,
availability_status user_availability_status DEFAULT 'offline' NOT NULL,
last_active_at TIMESTAMPTZ NULL,
CONSTRAINT constraint_users_on_country CHECK (LENGTH(country) <= 140), CONSTRAINT constraint_users_on_country CHECK (LENGTH(country) <= 140),
CONSTRAINT constraint_users_on_phone_number CHECK (LENGTH(phone_number) <= 20), CONSTRAINT constraint_users_on_phone_number CHECK (LENGTH(phone_number) <= 20),
CONSTRAINT constraint_users_on_email_length CHECK (LENGTH(email) <= 320), CONSTRAINT constraint_users_on_email_length CHECK (LENGTH(email) <= 320),