mirror of
				https://github.com/abhinavxd/libredesk.git
				synced 2025-11-04 05:53:30 +00:00 
			
		
		
		
	Compare commits
	
		
			30 Commits
		
	
	
		
			v0.1.0-alp
			...
			v0.3.1-alp
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					71601364ae | ||
| 
						 | 
					44723fb70d | ||
| 
						 | 
					67e1230485 | ||
| 
						 | 
					d58898c60f | ||
| 
						 | 
					a8dc0a6242 | ||
| 
						 | 
					3aa144f703 | ||
| 
						 | 
					fcbd16f042 | ||
| 
						 | 
					e8f3f24422 | ||
| 
						 | 
					425bb4ed04 | ||
| 
						 | 
					0c3da82250 | ||
| 
						 | 
					8649826a89 | ||
| 
						 | 
					d427dfd20c | ||
| 
						 | 
					afb54c371b | ||
| 
						 | 
					46459599c7 | ||
| 
						 | 
					63a6aedfd0 | ||
| 
						 | 
					ffbf613e68 | ||
| 
						 | 
					88f82fe80b | ||
| 
						 | 
					914b6371b6 | ||
| 
						 | 
					89eb05f337 | ||
| 
						 | 
					71a3588855 | ||
| 
						 | 
					c6baf3f9bf | ||
| 
						 | 
					368ec3c82b | ||
| 
						 | 
					4cc40ec5d5 | ||
| 
						 | 
					171e404e6f | ||
| 
						 | 
					28f4fda274 | ||
| 
						 | 
					00ded9c19b | ||
| 
						 | 
					17efaf0f2c | ||
| 
						 | 
					b44290a6f0 | ||
| 
						 | 
					1a7ee4d8c6 | ||
| 
						 | 
					ab56d01e22 | 
							
								
								
									
										1
									
								
								.gitattributes
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								.gitattributes
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
VERSION export-subst
 | 
			
		||||
							
								
								
									
										31
									
								
								.github/workflows/github-pages.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								.github/workflows/github-pages.yml
									
									
									
									
										vendored
									
									
										Normal 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
 | 
			
		||||
							
								
								
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -6,4 +6,5 @@ libredesk
 | 
			
		||||
libredesk.exe
 | 
			
		||||
uploads
 | 
			
		||||
.env
 | 
			
		||||
dist/
 | 
			
		||||
dist/
 | 
			
		||||
.vscode/
 | 
			
		||||
 
 | 
			
		||||
@@ -10,7 +10,7 @@ before:
 | 
			
		||||
    - make frontend-build
 | 
			
		||||
 | 
			
		||||
builds:
 | 
			
		||||
  - id: "standard"
 | 
			
		||||
  - id: "universal"
 | 
			
		||||
    main: ./cmd
 | 
			
		||||
    env:
 | 
			
		||||
      - CGO_ENABLED=0
 | 
			
		||||
@@ -24,29 +24,13 @@ builds:
 | 
			
		||||
    goarch:
 | 
			
		||||
      - amd64
 | 
			
		||||
      - 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
 | 
			
		||||
    goarm:
 | 
			
		||||
      - 6
 | 
			
		||||
      - 7
 | 
			
		||||
    binary: 'libredesk{{ if eq .Os "windows" }}.exe{{ end }}'
 | 
			
		||||
    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:
 | 
			
		||||
      post: make stuff BIN={{ .Path }}
 | 
			
		||||
 | 
			
		||||
@@ -70,7 +54,7 @@ dockers:
 | 
			
		||||
    goos: linux
 | 
			
		||||
    goarch: amd64
 | 
			
		||||
    ids:
 | 
			
		||||
      - standard
 | 
			
		||||
      - universal
 | 
			
		||||
    image_templates:
 | 
			
		||||
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-amd64"
 | 
			
		||||
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-amd64"
 | 
			
		||||
@@ -94,7 +78,7 @@ dockers:
 | 
			
		||||
    goos: linux
 | 
			
		||||
    goarch: arm64
 | 
			
		||||
    ids:
 | 
			
		||||
      - standard
 | 
			
		||||
      - universal
 | 
			
		||||
    image_templates:
 | 
			
		||||
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-arm64"
 | 
			
		||||
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-arm64"
 | 
			
		||||
@@ -119,7 +103,7 @@ dockers:
 | 
			
		||||
    goarch: arm
 | 
			
		||||
    goarm: 6
 | 
			
		||||
    ids:
 | 
			
		||||
      - arm
 | 
			
		||||
      - universal
 | 
			
		||||
    image_templates:
 | 
			
		||||
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-armv6"
 | 
			
		||||
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv6"
 | 
			
		||||
@@ -144,7 +128,7 @@ dockers:
 | 
			
		||||
    goarch: arm
 | 
			
		||||
    goarm: 7
 | 
			
		||||
    ids:
 | 
			
		||||
      - arm
 | 
			
		||||
      - universal
 | 
			
		||||
    image_templates:
 | 
			
		||||
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-armv7"
 | 
			
		||||
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv7"
 | 
			
		||||
@@ -195,4 +179,4 @@ release:
 | 
			
		||||
    owner: abhinavxd
 | 
			
		||||
    name: libredesk
 | 
			
		||||
  prerelease: auto
 | 
			
		||||
  draft: true
 | 
			
		||||
  draft: true
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										26
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										26
									
								
								Makefile
									
									
									
									
									
								
							@@ -1,8 +1,10 @@
 | 
			
		||||
# Build variables
 | 
			
		||||
LAST_COMMIT := $(shell git rev-parse --short HEAD)
 | 
			
		||||
LAST_COMMIT_DATE := $(shell git show -s --format=%ci ${LAST_COMMIT})
 | 
			
		||||
VERSION := $(shell git describe --tags) 
 | 
			
		||||
BUILDSTR := ${VERSION} (Commit: ${LAST_COMMIT_DATE} (${LAST_COMMIT}), Build: $(shell date +"%Y-%m-%d %H:%M:%S %z"))
 | 
			
		||||
# Try to get the commit hash from 1) git 2) the VERSION file 3) fallback.
 | 
			
		||||
LAST_COMMIT := $(or $(shell git rev-parse --short HEAD 2> /dev/null),$(shell head -n 1 VERSION | grep -oP -m 1 "^[a-z0-9]+$$"), "")
 | 
			
		||||
 | 
			
		||||
# Try to get the semver from 1) git 2) the VERSION file 3) fallback.
 | 
			
		||||
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
 | 
			
		||||
BIN := libredesk
 | 
			
		||||
@@ -30,13 +32,13 @@ install-deps: $(STUFFBIN)
 | 
			
		||||
.PHONY: frontend-build
 | 
			
		||||
frontend-build: install-deps
 | 
			
		||||
	@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.
 | 
			
		||||
.PHONY: run-backend
 | 
			
		||||
run-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.
 | 
			
		||||
.PHONY: run-frontend
 | 
			
		||||
@@ -44,19 +46,19 @@ run-frontend:
 | 
			
		||||
	@echo "→ Installing frontend dependencies (if not already installed)..."
 | 
			
		||||
	@cd ${FRONTEND_DIR} && pnpm install
 | 
			
		||||
	@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.
 | 
			
		||||
.PHONY: backend-build
 | 
			
		||||
backend-build: $(STUFFBIN)
 | 
			
		||||
.PHONY: build-backend
 | 
			
		||||
build-backend: $(STUFFBIN)
 | 
			
		||||
	@echo "→ Building backend..."
 | 
			
		||||
	@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
 | 
			
		||||
 | 
			
		||||
# Main build target: builds both frontend and backend, then stuffs static assets into the binary.
 | 
			
		||||
.PHONY: build
 | 
			
		||||
build: frontend-build backend-build stuff
 | 
			
		||||
build: frontend-build build-backend stuff
 | 
			
		||||
	@echo "→ Build successful. Current version: $(VERSION)"
 | 
			
		||||
 | 
			
		||||
# Stuff static assets into the binary using stuffbin.
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										84
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										84
									
								
								README.md
									
									
									
									
									
								
							@@ -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
 | 
			
		||||
@@ -10,37 +10,73 @@ Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**Live
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
> [!CAUTION]
 | 
			
		||||
> This project is currently in **alpha**. Features and APIs may change and are not yet fully tested.
 | 
			
		||||
> **CAUTION:** 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**
 | 
			
		||||
- **pnpm**
 | 
			
		||||
- **postgreSQL >= 13**
 | 
			
		||||
- **redis**
 | 
			
		||||
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)
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
   git clone https://github.com/abhinavxd/libredesk.git
 | 
			
		||||
   cd libredesk
 | 
			
		||||
   ```
 | 
			
		||||
# Copy the config.sample.toml to config.toml and edit it as needed.
 | 
			
		||||
cp config.sample.toml config.toml
 | 
			
		||||
 | 
			
		||||
2. **Create config file**:
 | 
			
		||||
# Run the services in the background.
 | 
			
		||||
docker compose up -d
 | 
			
		||||
 | 
			
		||||
   - Copy the sample configuration file `config.toml.sample` to `config.toml`:
 | 
			
		||||
    
 | 
			
		||||
       ```bash
 | 
			
		||||
       cp config.toml.sample config.toml
 | 
			
		||||
       ```
 | 
			
		||||
   - Edit the `config.toml` file to configure your postgres and redis connection settings.
 | 
			
		||||
# Setting System user password.
 | 
			
		||||
docker exec -it libredesk_app ./libredesk --set-system-user-password
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
3. **Run in development mode**:
 | 
			
		||||
Go to `http://localhost:9000` and login with username `System` and the password you set using the --set-system-user-password command.
 | 
			
		||||
 | 
			
		||||
   - Backend: `make run-backend`
 | 
			
		||||
   - Frontend: `make run-frontend`
 | 
			
		||||
See [installation docs](https://libredesk.io/docs/installation/)
 | 
			
		||||
 | 
			
		||||
__________________
 | 
			
		||||
 | 
			
		||||
### 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.
 | 
			
		||||
 | 
			
		||||
See [installation docs](https://libredesk.app/docs/installation)
 | 
			
		||||
__________________
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
## 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.
 | 
			
		||||
 
 | 
			
		||||
@@ -99,6 +99,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
 | 
			
		||||
	g.GET("/api/v1/users/me", auth(handleGetCurrentUser))
 | 
			
		||||
	g.PUT("/api/v1/users/me", auth(handleUpdateCurrentUser))
 | 
			
		||||
	g.GET("/api/v1/users/me/teams", auth(handleGetCurrentUserTeams))
 | 
			
		||||
	g.PUT("/api/v1/users/me/availability", auth(handleUpdateUserAvailability))
 | 
			
		||||
	g.DELETE("/api/v1/users/me/avatar", auth(handleDeleteAvatar))
 | 
			
		||||
	g.GET("/api/v1/users/compact", auth(handleGetUsersCompact))
 | 
			
		||||
	g.GET("/api/v1/users", perm(handleGetUsers, "users:manage"))
 | 
			
		||||
 
 | 
			
		||||
@@ -308,6 +308,11 @@ func initCSAT(db *sqlx.DB) *csat.Manager {
 | 
			
		||||
	return m
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// initWS inits websocket hub.
 | 
			
		||||
func initWS(user *user.Manager) *ws.Hub {
 | 
			
		||||
	return ws.NewHub(user)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// initTemplates inits template manager.
 | 
			
		||||
func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants) *tmpl.Manager {
 | 
			
		||||
	var (
 | 
			
		||||
@@ -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)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	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
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -9,10 +9,10 @@ import (
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"github.com/abhinavxd/libredesk/internal/colorlog"
 | 
			
		||||
	"github.com/abhinavxd/libredesk/internal/dbutil"
 | 
			
		||||
	"github.com/abhinavxd/libredesk/internal/user"
 | 
			
		||||
	"github.com/jmoiron/sqlx"
 | 
			
		||||
	"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.
 | 
			
		||||
@@ -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.
 | 
			
		||||
func checkSchema(db *sqlx.DB) (bool, error) {
 | 
			
		||||
	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, err
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										13
									
								
								cmd/login.go
									
									
									
									
									
								
							
							
						
						
									
										13
									
								
								cmd/login.go
									
									
									
									
									
								
							@@ -3,6 +3,7 @@ package main
 | 
			
		||||
import (
 | 
			
		||||
	amodels "github.com/abhinavxd/libredesk/internal/auth/models"
 | 
			
		||||
	"github.com/abhinavxd/libredesk/internal/envelope"
 | 
			
		||||
	umodels "github.com/abhinavxd/libredesk/internal/user/models"
 | 
			
		||||
	"github.com/valyala/fasthttp"
 | 
			
		||||
	"github.com/zerodha/fastglue"
 | 
			
		||||
)
 | 
			
		||||
@@ -11,14 +12,20 @@ import (
 | 
			
		||||
func handleLogin(r *fastglue.Request) error {
 | 
			
		||||
	var (
 | 
			
		||||
		app      = r.Context.(*App)
 | 
			
		||||
		p        = r.RequestCtx.PostArgs()
 | 
			
		||||
		email    = string(p.Peek("email"))
 | 
			
		||||
		password = p.Peek("password")
 | 
			
		||||
		email    = string(r.RequestCtx.PostArgs().Peek("email"))
 | 
			
		||||
		password = r.RequestCtx.PostArgs().Peek("password")
 | 
			
		||||
	)
 | 
			
		||||
	user, err := app.user.VerifyPassword(email, password)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Set user availability status to online.
 | 
			
		||||
	if err := app.user.UpdateAvailability(user.ID, umodels.Online); err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
	user.AvailabilityStatus = umodels.Online
 | 
			
		||||
 | 
			
		||||
	if err := app.auth.SaveSession(amodels.User{
 | 
			
		||||
		ID:        user.ID,
 | 
			
		||||
		Email:     user.Email.String,
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										28
									
								
								cmd/main.go
									
									
									
									
									
								
							
							
						
						
									
										28
									
								
								cmd/main.go
									
									
									
									
									
								
							@@ -6,8 +6,10 @@ import (
 | 
			
		||||
	"log"
 | 
			
		||||
	"os"
 | 
			
		||||
	"os/signal"
 | 
			
		||||
	"sync"
 | 
			
		||||
	"sync/atomic"
 | 
			
		||||
	"syscall"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"github.com/abhinavxd/libredesk/internal/ai"
 | 
			
		||||
	auth_ "github.com/abhinavxd/libredesk/internal/auth"
 | 
			
		||||
@@ -34,7 +36,6 @@ import (
 | 
			
		||||
	"github.com/abhinavxd/libredesk/internal/team"
 | 
			
		||||
	"github.com/abhinavxd/libredesk/internal/template"
 | 
			
		||||
	"github.com/abhinavxd/libredesk/internal/user"
 | 
			
		||||
	"github.com/abhinavxd/libredesk/internal/ws"
 | 
			
		||||
	"github.com/knadh/go-i18n"
 | 
			
		||||
	"github.com/knadh/koanf/v2"
 | 
			
		||||
	"github.com/knadh/stuffbin"
 | 
			
		||||
@@ -50,7 +51,8 @@ var (
 | 
			
		||||
	frontendDir = "frontend/dist"
 | 
			
		||||
 | 
			
		||||
	// Injected at build time.
 | 
			
		||||
	buildString = ""
 | 
			
		||||
	buildString   string
 | 
			
		||||
	versionString string
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// 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
 | 
			
		||||
	search        *search.Manager
 | 
			
		||||
	notifier      *notifier.Service
 | 
			
		||||
 | 
			
		||||
	// Global state that stores data on an available app update.
 | 
			
		||||
	update *AppUpdate
 | 
			
		||||
	sync.Mutex
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func main() {
 | 
			
		||||
@@ -99,9 +105,8 @@ func main() {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 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.
 | 
			
		||||
	initConfig(ko)
 | 
			
		||||
@@ -136,10 +141,13 @@ func main() {
 | 
			
		||||
 | 
			
		||||
	// Upgrade.
 | 
			
		||||
	if ko.Bool("upgrade") {
 | 
			
		||||
		log.Println("no upgrades available")
 | 
			
		||||
		upgrade(db, fs, !ko.Bool("yes"))
 | 
			
		||||
		os.Exit(0)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Check for pending upgrade.
 | 
			
		||||
	checkPendingUpgrade(db)
 | 
			
		||||
 | 
			
		||||
	// Load app settings from DB into the Koanf instance.
 | 
			
		||||
	settings := initSettings(db)
 | 
			
		||||
	loadSettings(settings)
 | 
			
		||||
@@ -153,7 +161,6 @@ func main() {
 | 
			
		||||
		messageOutgoingScanInterval = ko.MustDuration("message.message_outoing_scan_interval")
 | 
			
		||||
		slaEvaluationInterval       = ko.MustDuration("sla.evaluation_interval")
 | 
			
		||||
		lo                          = initLogger(appName)
 | 
			
		||||
		wsHub                       = ws.NewHub()
 | 
			
		||||
		rdb                         = initRedis()
 | 
			
		||||
		constants                   = initConstants()
 | 
			
		||||
		i18n                        = initI18n(fs)
 | 
			
		||||
@@ -168,6 +175,7 @@ func main() {
 | 
			
		||||
		team                        = initTeam(db)
 | 
			
		||||
		businessHours               = initBusinessHours(db)
 | 
			
		||||
		user                        = initUser(i18n, db)
 | 
			
		||||
		wsHub                       = initWS(user)
 | 
			
		||||
		notifier                    = initNotifier(user)
 | 
			
		||||
		automation                  = initAutomationEngine(db)
 | 
			
		||||
		sla                         = initSLA(db, team, settings, businessHours)
 | 
			
		||||
@@ -184,6 +192,7 @@ func main() {
 | 
			
		||||
	go notifier.Run(ctx)
 | 
			
		||||
	go sla.Run(ctx, slaEvaluationInterval)
 | 
			
		||||
	go media.DeleteUnlinkedMedia(ctx)
 | 
			
		||||
	go user.MonitorAgentAvailability(ctx)
 | 
			
		||||
 | 
			
		||||
	var app = &App{
 | 
			
		||||
		lo:            lo,
 | 
			
		||||
@@ -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.
 | 
			
		||||
	<-ctx.Done()
 | 
			
		||||
	colorlog.Red("Shutting down HTTP server...")
 | 
			
		||||
 
 | 
			
		||||
@@ -43,9 +43,7 @@ func tryAuth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
 | 
			
		||||
// auth makes sure the user is logged in.
 | 
			
		||||
func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
 | 
			
		||||
	return func(r *fastglue.Request) error {
 | 
			
		||||
		var (
 | 
			
		||||
			app = r.Context.(*App)
 | 
			
		||||
		)
 | 
			
		||||
		var app = r.Context.(*App)
 | 
			
		||||
 | 
			
		||||
		// Validate session and fetch user.
 | 
			
		||||
		userSession, err := app.auth.ValidateSession(r)
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										20
									
								
								cmd/roles.go
									
									
									
									
									
								
							
							
						
						
									
										20
									
								
								cmd/roles.go
									
									
									
									
									
								
							@@ -9,6 +9,7 @@ import (
 | 
			
		||||
	"github.com/zerodha/fastglue"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// handleGetRoles returns all roles
 | 
			
		||||
func handleGetRoles(r *fastglue.Request) error {
 | 
			
		||||
	var (
 | 
			
		||||
		app = r.Context.(*App)
 | 
			
		||||
@@ -20,6 +21,7 @@ func handleGetRoles(r *fastglue.Request) error {
 | 
			
		||||
	return r.SendEnvelope(agents)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleGetRole returns a single role
 | 
			
		||||
func handleGetRole(r *fastglue.Request) error {
 | 
			
		||||
	var (
 | 
			
		||||
		app   = r.Context.(*App)
 | 
			
		||||
@@ -32,18 +34,19 @@ func handleGetRole(r *fastglue.Request) error {
 | 
			
		||||
	return r.SendEnvelope(role)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleDeleteRole deletes a role
 | 
			
		||||
func handleDeleteRole(r *fastglue.Request) error {
 | 
			
		||||
	var (
 | 
			
		||||
		app   = r.Context.(*App)
 | 
			
		||||
		id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
 | 
			
		||||
	)
 | 
			
		||||
	err := app.role.Delete(id)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
	if err := app.role.Delete(id); err != nil {
 | 
			
		||||
		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 {
 | 
			
		||||
	var (
 | 
			
		||||
		app = r.Context.(*App)
 | 
			
		||||
@@ -52,13 +55,13 @@ func handleCreateRole(r *fastglue.Request) error {
 | 
			
		||||
	if err := r.Decode(&req, "json"); err != nil {
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
 | 
			
		||||
	}
 | 
			
		||||
	err := app.role.Create(req)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
	if err := app.role.Create(req); err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
	return r.SendEnvelope(true)
 | 
			
		||||
	return r.SendEnvelope("Role created successfully")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleUpdateRole updates a role
 | 
			
		||||
func handleUpdateRole(r *fastglue.Request) error {
 | 
			
		||||
	var (
 | 
			
		||||
		app   = r.Context.(*App)
 | 
			
		||||
@@ -68,9 +71,8 @@ func handleUpdateRole(r *fastglue.Request) error {
 | 
			
		||||
	if err := r.Decode(&req, "json"); err != nil {
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
 | 
			
		||||
	}
 | 
			
		||||
	err := app.role.Update(id, req)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
	if err := app.role.Update(id, req);err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
	return r.SendEnvelope(true)
 | 
			
		||||
	return r.SendEnvelope("Role updated successfully")
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -20,7 +20,15 @@ func handleGetGeneralSettings(r *fastglue.Request) error {
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		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.
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										98
									
								
								cmd/updates.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								cmd/updates.go
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										148
									
								
								cmd/upgrade.go
									
									
									
									
									
										Normal 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)
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										21
									
								
								cmd/users.go
									
									
									
									
									
								
							
							
						
						
									
										21
									
								
								cmd/users.go
									
									
									
									
									
								
							@@ -22,7 +22,7 @@ import (
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
const (
 | 
			
		||||
	maxAvatarSizeMB = 5
 | 
			
		||||
	maxAvatarSizeMB = 20
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// handleGetUsers returns all users.
 | 
			
		||||
@@ -39,9 +39,7 @@ func handleGetUsers(r *fastglue.Request) error {
 | 
			
		||||
 | 
			
		||||
// handleGetUsersCompact returns all users in a compact format.
 | 
			
		||||
func handleGetUsersCompact(r *fastglue.Request) error {
 | 
			
		||||
	var (
 | 
			
		||||
		app = r.Context.(*App)
 | 
			
		||||
	)
 | 
			
		||||
	var app = r.Context.(*App)
 | 
			
		||||
	agents, err := app.user.GetAllCompact()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
 | 
			
		||||
@@ -66,6 +64,19 @@ func handleGetUser(r *fastglue.Request) error {
 | 
			
		||||
	return r.SendEnvelope(user)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleUpdateUserAvailability updates the current user availability.
 | 
			
		||||
func handleUpdateUserAvailability(r *fastglue.Request) error {
 | 
			
		||||
	var (
 | 
			
		||||
		app    = r.Context.(*App)
 | 
			
		||||
		auser  = r.RequestCtx.UserValue("user").(amodels.User)
 | 
			
		||||
		status = string(r.RequestCtx.PostArgs().Peek("status"))
 | 
			
		||||
	)
 | 
			
		||||
	if err := app.user.UpdateAvailability(auser.ID, status); err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
	return r.SendEnvelope("User availability updated successfully.")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleGetCurrentUserTeams returns the teams of a user.
 | 
			
		||||
func handleGetCurrentUserTeams(r *fastglue.Request) error {
 | 
			
		||||
	var (
 | 
			
		||||
@@ -228,7 +239,7 @@ func handleCreateUser(r *fastglue.Request) error {
 | 
			
		||||
			Provider: notifier.ProviderEmail,
 | 
			
		||||
		}); err != nil {
 | 
			
		||||
			app.lo.Error("error sending notification message", "error", err)
 | 
			
		||||
			return r.SendEnvelope("User created successfully, but error sending welcome email.")
 | 
			
		||||
			return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "User created successfully, but could not send welcome email.", nil))
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return r.SendEnvelope("User created successfully.")
 | 
			
		||||
 
 | 
			
		||||
@@ -2,6 +2,7 @@
 | 
			
		||||
[app]
 | 
			
		||||
log_level = "debug"
 | 
			
		||||
env = "dev"
 | 
			
		||||
check_updates = true
 | 
			
		||||
 | 
			
		||||
# HTTP server.
 | 
			
		||||
[app.server]
 | 
			
		||||
@@ -12,7 +13,7 @@ write_timeout = "5s"
 | 
			
		||||
max_body_size = 500000000
 | 
			
		||||
keepalive_timeout = "10s"
 | 
			
		||||
 | 
			
		||||
# File upload provider to use.
 | 
			
		||||
# File upload provider to use, either `fs` or `s3`.
 | 
			
		||||
[upload]
 | 
			
		||||
provider = "fs"
 | 
			
		||||
 | 
			
		||||
@@ -32,7 +33,7 @@ expiry = "6h"
 | 
			
		||||
 | 
			
		||||
# Postgres.
 | 
			
		||||
[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"
 | 
			
		||||
port = 5432
 | 
			
		||||
user = "postgres"
 | 
			
		||||
@@ -45,7 +46,7 @@ max_lifetime = "300s"
 | 
			
		||||
 | 
			
		||||
# 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"
 | 
			
		||||
password = ""
 | 
			
		||||
db = 0
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
services:
 | 
			
		||||
  # Libredesk app
 | 
			
		||||
  app:
 | 
			
		||||
    image: libredesk:latest
 | 
			
		||||
    image: libredesk/libredesk:latest
 | 
			
		||||
    container_name: libredesk_app
 | 
			
		||||
    restart: unless-stopped
 | 
			
		||||
    ports:
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										31
									
								
								docs/docs/developer-setup.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								docs/docs/developer-setup.md
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										13
									
								
								docs/docs/index.md
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										48
									
								
								docs/docs/installation.md
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										18
									
								
								docs/docs/upgrade.md
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										34
									
								
								docs/mkdocs.yml
									
									
									
									
									
										Normal 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
 | 
			
		||||
							
								
								
									
										8
									
								
								frontend/.vscode/extensions.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								frontend/.vscode/extensions.json
									
									
									
									
										vendored
									
									
								
							@@ -1,8 +0,0 @@
 | 
			
		||||
{
 | 
			
		||||
  "recommendations": [
 | 
			
		||||
    "Vue.volar",
 | 
			
		||||
    "Vue.vscode-typescript-vue-plugin",
 | 
			
		||||
    "dbaeumer.vscode-eslint",
 | 
			
		||||
    "esbenp.prettier-vscode"
 | 
			
		||||
  ]
 | 
			
		||||
}
 | 
			
		||||
@@ -7,7 +7,7 @@
 | 
			
		||||
  <link rel="preconnect" href="https://fonts.googleapis.com">
 | 
			
		||||
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
 | 
			
		||||
  <link
 | 
			
		||||
    href="https://fonts.googleapis.com/css2?family=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">
 | 
			
		||||
</head>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
{
 | 
			
		||||
  "name": "libredesk",
 | 
			
		||||
  "version": "0.0.0",
 | 
			
		||||
  "version": "0.3.0",
 | 
			
		||||
  "private": true,
 | 
			
		||||
  "type": "module",
 | 
			
		||||
  "scripts": {
 | 
			
		||||
@@ -18,41 +18,29 @@
 | 
			
		||||
    "@formkit/auto-animate": "^0.8.2",
 | 
			
		||||
    "@internationalized/date": "^3.5.5",
 | 
			
		||||
    "@radix-icons/vue": "^1.0.0",
 | 
			
		||||
    "@tailwindcss/typography": "^0.5.10",
 | 
			
		||||
    "@tanstack/vue-table": "^8.19.2",
 | 
			
		||||
    "@tiptap/extension-image": "^2.5.9",
 | 
			
		||||
    "@tiptap/extension-link": "^2.9.1",
 | 
			
		||||
    "@tiptap/extension-ordered-list": "^2.4.0",
 | 
			
		||||
    "@tiptap/extension-placeholder": "^2.4.0",
 | 
			
		||||
    "@tiptap/pm": "^2.4.0",
 | 
			
		||||
    "@tiptap/starter-kit": "^2.4.0",
 | 
			
		||||
    "@tiptap/suggestion": "^2.4.0",
 | 
			
		||||
    "@tiptap/vue-3": "^2.4.0",
 | 
			
		||||
    "@unovis/ts": "^1.4.4",
 | 
			
		||||
    "@unovis/vue": "^1.4.4",
 | 
			
		||||
    "@vee-validate/zod": "^4.13.2",
 | 
			
		||||
    "@vue/reactivity": "^3.4.15",
 | 
			
		||||
    "@vue/runtime-core": "^3.4.15",
 | 
			
		||||
    "@vueup/vue-quill": "^1.2.0",
 | 
			
		||||
    "@vueuse/core": "^12.4.0",
 | 
			
		||||
    "add": "^2.0.6",
 | 
			
		||||
    "axios": "^1.7.9",
 | 
			
		||||
    "class-variance-authority": "^0.7.0",
 | 
			
		||||
    "clsx": "^2.1.1",
 | 
			
		||||
    "codeflask": "^1.4.1",
 | 
			
		||||
    "date-fns": "^3.6.0",
 | 
			
		||||
    "install": "^0.13.0",
 | 
			
		||||
    "lucide-vue-next": "^0.378.0",
 | 
			
		||||
    "mitt": "^3.0.1",
 | 
			
		||||
    "npm": "^10.4.0",
 | 
			
		||||
    "npx": "^10.2.2",
 | 
			
		||||
    "pinia": "^2.1.7",
 | 
			
		||||
    "qs": "^6.12.1",
 | 
			
		||||
    "radix-vue": "latest",
 | 
			
		||||
    "shadcn-vue": "latest",
 | 
			
		||||
    "tailwind-merge": "^2.3.0",
 | 
			
		||||
    "tailwindcss-animate": "^1.0.7",
 | 
			
		||||
    "textarea": "^0.3.0",
 | 
			
		||||
    "vee-validate": "^4.13.2",
 | 
			
		||||
    "vue": "^3.4.37",
 | 
			
		||||
    "vue-i18n": "9",
 | 
			
		||||
@@ -68,7 +56,7 @@
 | 
			
		||||
    "@rushstack/eslint-patch": "^1.3.3",
 | 
			
		||||
    "@vitejs/plugin-vue": "^5.0.3",
 | 
			
		||||
    "@vue/eslint-config-prettier": "^8.0.0",
 | 
			
		||||
    "autoprefixer": "latest",
 | 
			
		||||
    "autoprefixer": "^10.4.20",
 | 
			
		||||
    "cypress": "^13.6.3",
 | 
			
		||||
    "eslint": "^8.49.0",
 | 
			
		||||
    "eslint-plugin-cypress": "^2.15.1",
 | 
			
		||||
@@ -78,6 +66,7 @@
 | 
			
		||||
    "sass": "^1.70.0",
 | 
			
		||||
    "start-server-and-test": "^2.0.3",
 | 
			
		||||
    "tailwindcss": "latest",
 | 
			
		||||
    "tailwindcss-animate": "^1.0.7",
 | 
			
		||||
    "vite": "^5.4.9"
 | 
			
		||||
  },
 | 
			
		||||
  "packageManager": "pnpm@9.15.3+sha512.1f79bc245a66eb0b07c5d4d83131240774642caaa86ef7d0434ab47c0d16f66b04e21e0c086eb61e62c77efc4d7f7ec071afad3796af64892fae66509173893a"
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										1640
									
								
								frontend/pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1640
									
								
								frontend/pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -48,7 +48,13 @@
 | 
			
		||||
        @delete-view="deleteView"
 | 
			
		||||
      >
 | 
			
		||||
        <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 />
 | 
			
		||||
 | 
			
		||||
          <!-- Main content -->
 | 
			
		||||
          <RouterView class="flex-grow" />
 | 
			
		||||
        </div>
 | 
			
		||||
        <ViewForm v-model:openDialog="openCreateViewForm" v-model:view="view" />
 | 
			
		||||
@@ -75,8 +81,10 @@ import { useTeamStore } from '@/stores/team'
 | 
			
		||||
import { useSlaStore } from '@/stores/sla'
 | 
			
		||||
import { useMacroStore } from '@/stores/macro'
 | 
			
		||||
import { useTagStore } from '@/stores/tag'
 | 
			
		||||
import { useIdleDetection } from '@/composables/useIdleDetection'
 | 
			
		||||
import PageHeader from './components/layout/PageHeader.vue'
 | 
			
		||||
import ViewForm from '@/features/view/ViewForm.vue'
 | 
			
		||||
import AppUpdate from '@/components/update/AppUpdate.vue'
 | 
			
		||||
import api from '@/api'
 | 
			
		||||
import { toast as sooner } from 'vue-sonner'
 | 
			
		||||
import Sidebar from '@/components/sidebar/Sidebar.vue'
 | 
			
		||||
@@ -111,6 +119,8 @@ const view = ref({})
 | 
			
		||||
const openCreateViewForm = ref(false)
 | 
			
		||||
 | 
			
		||||
initWS()
 | 
			
		||||
useIdleDetection()
 | 
			
		||||
 | 
			
		||||
onMounted(() => {
 | 
			
		||||
  initToaster()
 | 
			
		||||
  listenViewRefresh()
 | 
			
		||||
@@ -119,8 +129,10 @@ onMounted(() => {
 | 
			
		||||
 | 
			
		||||
// initialize data stores
 | 
			
		||||
const initStores = async () => {
 | 
			
		||||
  if (!userStore.userID) {
 | 
			
		||||
    await userStore.getCurrentUser()
 | 
			
		||||
  }
 | 
			
		||||
  await Promise.allSettled([
 | 
			
		||||
    userStore.getCurrentUser(),
 | 
			
		||||
    getUserViews(),
 | 
			
		||||
    conversationStore.fetchStatuses(),
 | 
			
		||||
    conversationStore.fetchPriorities(),
 | 
			
		||||
 
 | 
			
		||||
@@ -169,6 +169,7 @@ const updateCurrentUser = (data) =>
 | 
			
		||||
const deleteUserAvatar = () => http.delete('/api/v1/users/me/avatar')
 | 
			
		||||
const getCurrentUser = () => http.get('/api/v1/users/me')
 | 
			
		||||
const getCurrentUserTeams = () => http.get('/api/v1/users/me/teams')
 | 
			
		||||
const updateCurrentUserAvailability = (data) => http.put('/api/v1/users/me/availability', data)
 | 
			
		||||
const getTags = () => http.get('/api/v1/tags')
 | 
			
		||||
const upsertTags = (uuid, data) => http.post(`/api/v1/conversations/${uuid}/tags`, data)
 | 
			
		||||
const updateAssignee = (uuid, assignee_type, data) => http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}`, data)
 | 
			
		||||
@@ -323,6 +324,7 @@ export default {
 | 
			
		||||
  uploadMedia,
 | 
			
		||||
  updateAssigneeLastSeen,
 | 
			
		||||
  updateUser,
 | 
			
		||||
  updateCurrentUserAvailability,
 | 
			
		||||
  updateAutomationRule,
 | 
			
		||||
  updateAutomationRuleWeights,
 | 
			
		||||
  updateAutomationRulesExecutionMode,
 | 
			
		||||
 
 | 
			
		||||
@@ -312,3 +312,7 @@ a[data-active='false']:hover {
 | 
			
		||||
    opacity: 1;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
[data-radix-popper-content-wrapper] {
 | 
			
		||||
  z-index: 9999 !important;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,82 +1,93 @@
 | 
			
		||||
<template>
 | 
			
		||||
    <DropdownMenu>
 | 
			
		||||
        <DropdownMenuTrigger as-child>
 | 
			
		||||
            <SidebarMenuButton size="lg"
 | 
			
		||||
                class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground p-0">
 | 
			
		||||
                <Avatar class="h-8 w-8 rounded-lg">
 | 
			
		||||
                    <AvatarImage :src="userStore.avatar" alt="Abhinav" />
 | 
			
		||||
                    <AvatarFallback class="rounded-lg">
 | 
			
		||||
                        {{ userStore.getInitials }}
 | 
			
		||||
                    </AvatarFallback>
 | 
			
		||||
                </Avatar>
 | 
			
		||||
                <div class="grid flex-1 text-left text-sm leading-tight">
 | 
			
		||||
                    <span class="truncate font-semibold">{{ userStore.getFullName }}</span>
 | 
			
		||||
                    <span class="truncate text-xs">{{ userStore.email }}</span>
 | 
			
		||||
                </div>
 | 
			
		||||
                <ChevronsUpDown class="ml-auto size-4" />
 | 
			
		||||
            </SidebarMenuButton>
 | 
			
		||||
        </DropdownMenuTrigger>
 | 
			
		||||
        <DropdownMenuContent class="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg" side="bottom"
 | 
			
		||||
            :side-offset="4">
 | 
			
		||||
            <DropdownMenuLabel class="p-0 font-normal">
 | 
			
		||||
                <div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
 | 
			
		||||
                    <Avatar class="h-8 w-8 rounded-lg">
 | 
			
		||||
                        <AvatarImage :src="userStore.avatar" alt="Abhinav" />
 | 
			
		||||
                        <AvatarFallback class="rounded-lg">
 | 
			
		||||
                            {{ userStore.getInitials }}
 | 
			
		||||
                        </AvatarFallback>
 | 
			
		||||
                    </Avatar>
 | 
			
		||||
                    <div class="grid flex-1 text-left text-sm leading-tight">
 | 
			
		||||
                        <span class="truncate font-semibold">{{ userStore.getFullName }}</span>
 | 
			
		||||
                        <span class="truncate text-xs">{{ userStore.email }}</span>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
            </DropdownMenuLabel>
 | 
			
		||||
            <DropdownMenuSeparator />
 | 
			
		||||
            <DropdownMenuGroup>
 | 
			
		||||
                <DropdownMenuItem>
 | 
			
		||||
                    <router-link to="/account" class="flex items-center">
 | 
			
		||||
                        <CircleUserRound size="18" class="mr-2" />
 | 
			
		||||
                        Account
 | 
			
		||||
                    </router-link>
 | 
			
		||||
                </DropdownMenuItem>
 | 
			
		||||
            </DropdownMenuGroup>
 | 
			
		||||
            <DropdownMenuSeparator />
 | 
			
		||||
            <DropdownMenuItem @click="logout">
 | 
			
		||||
                <LogOut size="18" class="mr-2" />
 | 
			
		||||
                Log out
 | 
			
		||||
            </DropdownMenuItem>
 | 
			
		||||
        </DropdownMenuContent>
 | 
			
		||||
    </DropdownMenu>
 | 
			
		||||
  <DropdownMenu>
 | 
			
		||||
    <DropdownMenuTrigger as-child>
 | 
			
		||||
      <SidebarMenuButton
 | 
			
		||||
        size="lg"
 | 
			
		||||
        class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground p-0"
 | 
			
		||||
      >
 | 
			
		||||
        <Avatar class="h-8 w-8 rounded-lg relative overflow-visible">
 | 
			
		||||
          <AvatarImage :src="userStore.avatar" alt="Abhinav" />
 | 
			
		||||
          <AvatarFallback class="rounded-lg">
 | 
			
		||||
            {{ userStore.getInitials }}
 | 
			
		||||
          </AvatarFallback>
 | 
			
		||||
          <div
 | 
			
		||||
            class="absolute bottom-0 right-0 h-2.5 w-2.5 rounded-full border border-background"
 | 
			
		||||
            :class="{
 | 
			
		||||
              'bg-green-500': userStore.user.availability_status === 'online',
 | 
			
		||||
              'bg-amber-500': userStore.user.availability_status === 'away' || userStore.user.availability_status === 'away_manual',
 | 
			
		||||
              'bg-gray-400': userStore.user.availability_status === 'offline'
 | 
			
		||||
            }"
 | 
			
		||||
          ></div>
 | 
			
		||||
        </Avatar>
 | 
			
		||||
        <div class="grid flex-1 text-left text-sm leading-tight">
 | 
			
		||||
          <span class="truncate font-semibold">{{ userStore.getFullName }}</span>
 | 
			
		||||
          <span class="truncate text-xs">{{ userStore.email }}</span>
 | 
			
		||||
        </div>
 | 
			
		||||
        <ChevronsUpDown class="ml-auto size-4" />
 | 
			
		||||
      </SidebarMenuButton>
 | 
			
		||||
    </DropdownMenuTrigger>
 | 
			
		||||
    <DropdownMenuContent
 | 
			
		||||
      class="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
 | 
			
		||||
      side="bottom"
 | 
			
		||||
      :side-offset="4"
 | 
			
		||||
    >
 | 
			
		||||
      <DropdownMenuLabel class="p-0 font-normal space-y-1">
 | 
			
		||||
        <div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
 | 
			
		||||
          <Avatar class="h-8 w-8 rounded-lg">
 | 
			
		||||
            <AvatarImage :src="userStore.avatar" alt="Abhinav" />
 | 
			
		||||
            <AvatarFallback class="rounded-lg">
 | 
			
		||||
              {{ userStore.getInitials }}
 | 
			
		||||
            </AvatarFallback>
 | 
			
		||||
          </Avatar>
 | 
			
		||||
          <div class="grid flex-1 text-left text-sm leading-tight">
 | 
			
		||||
            <span class="truncate font-semibold">{{ userStore.getFullName }}</span>
 | 
			
		||||
            <span class="truncate text-xs">{{ userStore.email }}</span>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm justify-between">
 | 
			
		||||
          <span class="text-muted-foreground">Away</span>
 | 
			
		||||
          <Switch
 | 
			
		||||
            :checked="userStore.user.availability_status === 'away' || userStore.user.availability_status === 'away_manual'"
 | 
			
		||||
            @update:checked="(val) => userStore.updateUserAvailability(val ? 'away' : 'online')"
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
      </DropdownMenuLabel>
 | 
			
		||||
      <DropdownMenuSeparator />
 | 
			
		||||
      <DropdownMenuGroup>
 | 
			
		||||
        <DropdownMenuItem>
 | 
			
		||||
          <router-link to="/account" class="flex items-center">
 | 
			
		||||
            <CircleUserRound size="18" class="mr-2" />
 | 
			
		||||
            Account
 | 
			
		||||
          </router-link>
 | 
			
		||||
        </DropdownMenuItem>
 | 
			
		||||
      </DropdownMenuGroup>
 | 
			
		||||
      <DropdownMenuSeparator />
 | 
			
		||||
      <DropdownMenuItem @click="logout">
 | 
			
		||||
        <LogOut size="18" class="mr-2" />
 | 
			
		||||
        Log out
 | 
			
		||||
      </DropdownMenuItem>
 | 
			
		||||
    </DropdownMenuContent>
 | 
			
		||||
  </DropdownMenu>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup>
 | 
			
		||||
import {
 | 
			
		||||
    DropdownMenu,
 | 
			
		||||
    DropdownMenuContent,
 | 
			
		||||
    DropdownMenuGroup,
 | 
			
		||||
    DropdownMenuItem,
 | 
			
		||||
    DropdownMenuLabel,
 | 
			
		||||
    DropdownMenuSeparator,
 | 
			
		||||
    DropdownMenuTrigger,
 | 
			
		||||
  DropdownMenu,
 | 
			
		||||
  DropdownMenuContent,
 | 
			
		||||
  DropdownMenuGroup,
 | 
			
		||||
  DropdownMenuItem,
 | 
			
		||||
  DropdownMenuLabel,
 | 
			
		||||
  DropdownMenuSeparator,
 | 
			
		||||
  DropdownMenuTrigger
 | 
			
		||||
} from '@/components/ui/dropdown-menu'
 | 
			
		||||
import {
 | 
			
		||||
    SidebarMenuButton,
 | 
			
		||||
} from '@/components/ui/sidebar'
 | 
			
		||||
import {
 | 
			
		||||
    Avatar,
 | 
			
		||||
    AvatarFallback,
 | 
			
		||||
    AvatarImage,
 | 
			
		||||
} from '@/components/ui/avatar'
 | 
			
		||||
import {
 | 
			
		||||
    ChevronsUpDown,
 | 
			
		||||
    CircleUserRound,
 | 
			
		||||
    LogOut,
 | 
			
		||||
} from 'lucide-vue-next'
 | 
			
		||||
import { SidebarMenuButton } from '@/components/ui/sidebar'
 | 
			
		||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
 | 
			
		||||
import { Switch } from '@/components/ui/switch'
 | 
			
		||||
import { ChevronsUpDown, CircleUserRound, LogOut } from 'lucide-vue-next'
 | 
			
		||||
import { useUserStore } from '@/stores/user'
 | 
			
		||||
const userStore = useUserStore()
 | 
			
		||||
 | 
			
		||||
const logout = () => {
 | 
			
		||||
    window.location.href = '/logout'
 | 
			
		||||
  window.location.href = '/logout'
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
</script>
 | 
			
		||||
 
 | 
			
		||||
@@ -3,6 +3,7 @@ import { Primitive } from 'radix-vue'
 | 
			
		||||
import { buttonVariants } from '.'
 | 
			
		||||
import { cn } from '@/lib/utils'
 | 
			
		||||
import { ref, computed } from 'vue'
 | 
			
		||||
import { DotLoader } from '@/components/ui/loader'
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  variant: { type: null, required: false },
 | 
			
		||||
@@ -29,11 +30,7 @@ const computedClass = computed(() => {
 | 
			
		||||
    :class="computedClass"
 | 
			
		||||
    :disabled="isLoading || isDisabled"
 | 
			
		||||
  >
 | 
			
		||||
    <span v-if="isLoading" class="dot-loader">
 | 
			
		||||
      <span class="dot"></span>
 | 
			
		||||
      <span class="dot"></span>
 | 
			
		||||
      <span class="dot"></span>
 | 
			
		||||
    </span>
 | 
			
		||||
    <DotLoader v-if="isLoading" />
 | 
			
		||||
    <slot v-else />
 | 
			
		||||
  </Primitive>
 | 
			
		||||
</template>
 | 
			
		||||
 
 | 
			
		||||
@@ -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>
 | 
			
		||||
@@ -1 +0,0 @@
 | 
			
		||||
export { default as DonutChart } from './DonutChart.vue'
 | 
			
		||||
@@ -1,9 +1,7 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="flex flex-col items-center justify-center text-gray-600 dark:text-gray-300">
 | 
			
		||||
    <span class="dot-loader">
 | 
			
		||||
      <span class="dot"></span>
 | 
			
		||||
      <span class="dot"></span>
 | 
			
		||||
      <span class="dot"></span>
 | 
			
		||||
    </span>
 | 
			
		||||
  </div>
 | 
			
		||||
  <span class="dot-loader">
 | 
			
		||||
    <span class="dot"></span>
 | 
			
		||||
    <span class="dot"></span>
 | 
			
		||||
    <span class="dot"></span>
 | 
			
		||||
  </span>
 | 
			
		||||
</template>
 | 
			
		||||
 
 | 
			
		||||
@@ -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>
 | 
			
		||||
@@ -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>
 | 
			
		||||
@@ -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'
 | 
			
		||||
@@ -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>
 | 
			
		||||
@@ -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>
 | 
			
		||||
@@ -1,2 +0,0 @@
 | 
			
		||||
export { default as ScrollArea } from './ScrollArea.vue'
 | 
			
		||||
export { default as ScrollBar } from './ScrollBar.vue'
 | 
			
		||||
@@ -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>
 | 
			
		||||
@@ -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>
 | 
			
		||||
@@ -1,2 +0,0 @@
 | 
			
		||||
export { default as ToggleGroup } from './ToggleGroup.vue'
 | 
			
		||||
export { default as ToggleGroupItem } from './ToggleGroupItem.vue'
 | 
			
		||||
							
								
								
									
										25
									
								
								frontend/src/components/update/AppUpdate.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								frontend/src/components/update/AppUpdate.vue
									
									
									
									
									
										Normal 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>
 | 
			
		||||
							
								
								
									
										43
									
								
								frontend/src/composables/useIdleDetection.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								frontend/src/composables/useIdleDetection.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,43 @@
 | 
			
		||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
 | 
			
		||||
import { useUserStore } from '@/stores/user'
 | 
			
		||||
import { debounce } from '@/utils/debounce'
 | 
			
		||||
 | 
			
		||||
export function useIdleDetection () {
 | 
			
		||||
    const userStore = useUserStore()
 | 
			
		||||
    // 4 minutes
 | 
			
		||||
    const AWAY_THRESHOLD = 4 * 60 * 1000
 | 
			
		||||
    // 1 minute
 | 
			
		||||
    const CHECK_INTERVAL = 60 * 1000
 | 
			
		||||
    const lastActivity = ref(Date.now())
 | 
			
		||||
    const timer = ref(null)
 | 
			
		||||
 | 
			
		||||
    function resetTimer () {
 | 
			
		||||
        if (userStore.user.availability_status === 'away' || userStore.user.availability_status === 'offline') {
 | 
			
		||||
            userStore.updateUserAvailability('online', false)
 | 
			
		||||
        }
 | 
			
		||||
        lastActivity.value = Date.now()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const debouncedResetTimer = debounce(resetTimer, 200)
 | 
			
		||||
 | 
			
		||||
    function checkIdle () {
 | 
			
		||||
        if (Date.now() - lastActivity.value > AWAY_THRESHOLD &&
 | 
			
		||||
            userStore.user.availability_status === 'online') {
 | 
			
		||||
            userStore.updateUserAvailability('away', false)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    onMounted(() => {
 | 
			
		||||
        window.addEventListener('mousemove', debouncedResetTimer)
 | 
			
		||||
        window.addEventListener('keypress', debouncedResetTimer)
 | 
			
		||||
        window.addEventListener('click', debouncedResetTimer)
 | 
			
		||||
        timer.value = setInterval(checkIdle, CHECK_INTERVAL)
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    onBeforeUnmount(() => {
 | 
			
		||||
        window.removeEventListener('mousemove', debouncedResetTimer)
 | 
			
		||||
        window.removeEventListener('keypress', debouncedResetTimer)
 | 
			
		||||
        window.removeEventListener('click', debouncedResetTimer)
 | 
			
		||||
        clearInterval(timer.value)
 | 
			
		||||
    })
 | 
			
		||||
}
 | 
			
		||||
@@ -1,14 +1,16 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <Dialog v-model:open="dialogOpen">
 | 
			
		||||
    <DropdownMenu>
 | 
			
		||||
      <DropdownMenuTrigger
 | 
			
		||||
        as-child
 | 
			
		||||
        v-if="!CONVERSATION_DEFAULT_STATUSES_LIST.includes(props.status.name)"
 | 
			
		||||
      >
 | 
			
		||||
        <Button variant="ghost" class="w-8 h-8 p-0">
 | 
			
		||||
      <DropdownMenuTrigger as-child>
 | 
			
		||||
        <Button
 | 
			
		||||
          variant="ghost"
 | 
			
		||||
          class="w-8 h-8 p-0"
 | 
			
		||||
          v-if="!CONVERSATION_DEFAULT_STATUSES_LIST.includes(props.status.name)"
 | 
			
		||||
        >
 | 
			
		||||
          <span class="sr-only">Open menu</span>
 | 
			
		||||
          <MoreHorizontal class="w-4 h-4" />
 | 
			
		||||
        </Button>
 | 
			
		||||
        <div v-else class="w-8 h-8 p-0 invisible"></div>
 | 
			
		||||
      </DropdownMenuTrigger>
 | 
			
		||||
      <DropdownMenuContent>
 | 
			
		||||
        <DialogTrigger as-child>
 | 
			
		||||
 
 | 
			
		||||
@@ -124,7 +124,9 @@
 | 
			
		||||
          <Loader2 v-if="isLoading" class="mr-2 h-4 w-4 animate-spin" />
 | 
			
		||||
          {{ isLoading ? 'Loading...' : 'Load more' }}
 | 
			
		||||
        </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>
 | 
			
		||||
 
 | 
			
		||||
@@ -39,7 +39,7 @@
 | 
			
		||||
 | 
			
		||||
        <!-- Message preview and unread count -->
 | 
			
		||||
        <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
 | 
			
		||||
              class="text-green-600 flex-shrink-0"
 | 
			
		||||
              size="15"
 | 
			
		||||
 
 | 
			
		||||
@@ -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"
 | 
			
		||||
      >
 | 
			
		||||
        <div class="flex items-center space-x-1 py-1">
 | 
			
		||||
          <span v-if="attachment.loading" class="dot-loader">
 | 
			
		||||
            <span class="dot"></span>
 | 
			
		||||
            <span class="dot"></span>
 | 
			
		||||
            <span class="dot"></span>
 | 
			
		||||
          </span>
 | 
			
		||||
          <DotLoader v-if="attachment.loading"/>
 | 
			
		||||
          <PaperclipIcon v-else size="16" class="text-gray-500 group-hover:text-primary" />
 | 
			
		||||
 | 
			
		||||
          <Tooltip>
 | 
			
		||||
@@ -48,6 +44,7 @@
 | 
			
		||||
import { computed } from 'vue'
 | 
			
		||||
import { formatBytes } from '@/utils/file.js'
 | 
			
		||||
import { X, Paperclip as PaperclipIcon } from 'lucide-vue-next'
 | 
			
		||||
import { DotLoader } from '@/components/ui/loader'
 | 
			
		||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
    hi
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup></script>
 | 
			
		||||
@@ -6,7 +6,7 @@
 | 
			
		||||
      collapsible
 | 
			
		||||
      :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">
 | 
			
		||||
          Actions
 | 
			
		||||
        </AccordionTrigger>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,25 +1,36 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="flex flex-1 flex-col gap-x-5 box p-5 space-y-5 bg-white">
 | 
			
		||||
    <div class="flex items-center space-x-2">
 | 
			
		||||
      <p class="text-2xl">{{ title }}</p>
 | 
			
		||||
      <p class="text-2xl flex items-center">{{ title }}</p>
 | 
			
		||||
      <div class="bg-green-100/70 flex items-center space-x-2 px-1 rounded">
 | 
			
		||||
        <span class="blinking-dot"></span>
 | 
			
		||||
        <p class="uppercase text-xs">Live</p>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="flex justify-between pr-32">
 | 
			
		||||
      <div v-for="(value, key) in counts" :key="key" class="flex flex-col items-center gap-y-2">
 | 
			
		||||
      <div
 | 
			
		||||
        v-for="(item, key) in filteredCounts"
 | 
			
		||||
        :key="key"
 | 
			
		||||
        class="flex flex-col items-center gap-y-2"
 | 
			
		||||
      >
 | 
			
		||||
        <span class="text-muted-foreground">{{ labels[key] }}</span>
 | 
			
		||||
        <span class="text-2xl font-medium">{{ value }}</span>
 | 
			
		||||
        <span class="text-2xl font-medium">{{ item }}</span>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup>
 | 
			
		||||
defineProps({
 | 
			
		||||
import { computed } from 'vue'
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  counts: { type: Object, required: true },
 | 
			
		||||
  labels: { type: Object, required: true },
 | 
			
		||||
  title: { type: String, required: true }
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
// Filter out counts that don't have a label
 | 
			
		||||
const filteredCounts = computed(() => {
 | 
			
		||||
  return Object.fromEntries(Object.entries(props.counts).filter(([key]) => props.labels[key]))
 | 
			
		||||
})
 | 
			
		||||
</script>
 | 
			
		||||
 
 | 
			
		||||
@@ -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>
 | 
			
		||||
@@ -1,6 +1,7 @@
 | 
			
		||||
import { createApp } from 'vue'
 | 
			
		||||
import { createPinia } from 'pinia'
 | 
			
		||||
import { createI18n } from 'vue-i18n'
 | 
			
		||||
import { useAppSettingsStore } from './stores/appSettings'
 | 
			
		||||
import router from './router'
 | 
			
		||||
import mitt from 'mitt'
 | 
			
		||||
import api from './api'
 | 
			
		||||
@@ -38,12 +39,16 @@ async function initApp () {
 | 
			
		||||
  const i18n = createI18n(i18nConfig)
 | 
			
		||||
  const app = createApp(Root)
 | 
			
		||||
  const pinia = createPinia()
 | 
			
		||||
  app.use(pinia)
 | 
			
		||||
 | 
			
		||||
  // Store app settings in Pinia
 | 
			
		||||
  const settingsStore = useAppSettingsStore()
 | 
			
		||||
  settingsStore.setSettings(settings)
 | 
			
		||||
 | 
			
		||||
  // Add emitter to global properties.
 | 
			
		||||
  app.config.globalProperties.emitter = emitter
 | 
			
		||||
 | 
			
		||||
  app.use(router)
 | 
			
		||||
  app.use(pinia)
 | 
			
		||||
  app.use(i18n)
 | 
			
		||||
  app.mount('#app')
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										12
									
								
								frontend/src/stores/appSettings.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								frontend/src/stores/appSettings.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,12 @@
 | 
			
		||||
import { defineStore } from 'pinia'
 | 
			
		||||
 | 
			
		||||
export const useAppSettingsStore = defineStore('settings', {
 | 
			
		||||
    state: () => ({
 | 
			
		||||
        settings: {}
 | 
			
		||||
    }),
 | 
			
		||||
    actions: {
 | 
			
		||||
        setSettings (newSettings) {
 | 
			
		||||
            this.settings = newSettings
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
})
 | 
			
		||||
@@ -282,8 +282,10 @@ export const useConversationStore = defineStore('conversation', () => {
 | 
			
		||||
  async function fetchMessages (uuid, fetchNextPage = false) {
 | 
			
		||||
    // Messages are already cached?
 | 
			
		||||
    let hasMessages = messages.data.getAllPagesMessages(uuid)
 | 
			
		||||
    if (hasMessages.length > 0 && !fetchNextPage)
 | 
			
		||||
    if (hasMessages.length > 0 && !fetchNextPage) {
 | 
			
		||||
      markConversationAsRead(uuid)
 | 
			
		||||
      return
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Fetch messages from server.
 | 
			
		||||
    messages.loading = true
 | 
			
		||||
@@ -293,7 +295,6 @@ export const useConversationStore = defineStore('conversation', () => {
 | 
			
		||||
      const response = await api.getConversationMessages(uuid, { page: page, page_size: MESSAGE_LIST_PAGE_SIZE })
 | 
			
		||||
      const result = response.data?.data || {}
 | 
			
		||||
      const newMessages = result.results || []
 | 
			
		||||
      // Mark conversation as read
 | 
			
		||||
      markConversationAsRead(uuid)
 | 
			
		||||
      // Cache messages
 | 
			
		||||
      messages.data.addMessages(uuid, newMessages, result.page, result.total_pages)
 | 
			
		||||
 
 | 
			
		||||
@@ -15,14 +15,15 @@ export const useUserStore = defineStore('user', () => {
 | 
			
		||||
    avatar_url: '',
 | 
			
		||||
    email: '',
 | 
			
		||||
    teams: [],
 | 
			
		||||
    permissions: []
 | 
			
		||||
    permissions: [],
 | 
			
		||||
    availability_status: 'offline'
 | 
			
		||||
  })
 | 
			
		||||
  const emitter = useEmitter()
 | 
			
		||||
 | 
			
		||||
  const userID = computed(() => user.value.id)
 | 
			
		||||
  const firstName = computed(() => user.value.first_name)
 | 
			
		||||
  const lastName = computed(() => user.value.last_name)
 | 
			
		||||
  const avatar = computed(() => user.value.avatar_url)
 | 
			
		||||
  const firstName = computed(() => user.value.first_name || '')
 | 
			
		||||
  const lastName = computed(() => user.value.last_name || '')
 | 
			
		||||
  const avatar = computed(() => user.value.avatar_url || '')
 | 
			
		||||
  const permissions = computed(() => user.value.permissions || [])
 | 
			
		||||
  const email = computed(() => user.value.email)
 | 
			
		||||
  const teams = computed(() => user.value.teams || [])
 | 
			
		||||
@@ -71,6 +72,10 @@ export const useUserStore = defineStore('user', () => {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const setCurrentUser = (userData) => {
 | 
			
		||||
    user.value = userData
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const setAvatar = (avatarURL) => {
 | 
			
		||||
    if (typeof avatarURL !== 'string') {
 | 
			
		||||
      console.warn('Avatar URL must be a string')
 | 
			
		||||
@@ -83,6 +88,16 @@ export const useUserStore = defineStore('user', () => {
 | 
			
		||||
    user.value.avatar_url = ''
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const updateUserAvailability = async (status, isManual = true) => {
 | 
			
		||||
    try {
 | 
			
		||||
      const apiStatus = status === 'away' && isManual ? 'away_manual' : status
 | 
			
		||||
      await api.updateCurrentUserAvailability({ status: apiStatus })
 | 
			
		||||
      user.value.availability_status = apiStatus
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      if (error?.response?.status === 401) window.location.href = '/'
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    user,
 | 
			
		||||
    userID,
 | 
			
		||||
@@ -96,9 +111,11 @@ export const useUserStore = defineStore('user', () => {
 | 
			
		||||
    getInitials,
 | 
			
		||||
    hasAdminTabPermissions,
 | 
			
		||||
    hasReportTabPermissions,
 | 
			
		||||
    setCurrentUser,
 | 
			
		||||
    getCurrentUser,
 | 
			
		||||
    clearAvatar,
 | 
			
		||||
    setAvatar,
 | 
			
		||||
    updateUserAvailability,
 | 
			
		||||
    can
 | 
			
		||||
  }
 | 
			
		||||
})
 | 
			
		||||
})
 | 
			
		||||
							
								
								
									
										7
									
								
								frontend/src/utils/debounce.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								frontend/src/utils/debounce.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,7 @@
 | 
			
		||||
export function debounce (fn, delay) {
 | 
			
		||||
    let timeout
 | 
			
		||||
    return function (...args) {
 | 
			
		||||
        clearTimeout(timeout)
 | 
			
		||||
        timeout = setTimeout(() => fn(...args), delay)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -11,10 +11,7 @@
 | 
			
		||||
      </div>
 | 
			
		||||
    </template>
 | 
			
		||||
    <template #help>
 | 
			
		||||
      <p>
 | 
			
		||||
        Configure core helpdesk settings like helpdesk name, timezone, business hours, and more.
 | 
			
		||||
      </p>
 | 
			
		||||
      <p>These settings affect your entire helpdesk system.</p>
 | 
			
		||||
      <p>General settings for your support desk like timezone, working hours, etc.</p>
 | 
			
		||||
    </template>
 | 
			
		||||
  </AdminPageWithHelp>
 | 
			
		||||
</template>
 | 
			
		||||
 
 | 
			
		||||
@@ -5,7 +5,6 @@
 | 
			
		||||
    </template>
 | 
			
		||||
    <template #help>
 | 
			
		||||
      <p>Combine multiple conversation actions into single-click macros.</p>
 | 
			
		||||
      <p>Create reusable action sets for common agent responses.</p>
 | 
			
		||||
    </template>
 | 
			
		||||
  </AdminPageWithHelp>
 | 
			
		||||
</template>
 | 
			
		||||
 
 | 
			
		||||
@@ -5,7 +5,7 @@
 | 
			
		||||
    </template>
 | 
			
		||||
 | 
			
		||||
    <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>
 | 
			
		||||
  </AdminPageWithHelp>
 | 
			
		||||
</template>
 | 
			
		||||
 
 | 
			
		||||
@@ -5,7 +5,7 @@
 | 
			
		||||
    </template>
 | 
			
		||||
 | 
			
		||||
    <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>
 | 
			
		||||
  </AdminPageWithHelp>
 | 
			
		||||
</template>
 | 
			
		||||
 
 | 
			
		||||
@@ -34,7 +34,7 @@
 | 
			
		||||
    </template>
 | 
			
		||||
 | 
			
		||||
    <template #help>
 | 
			
		||||
      <p>Add custom conversation statuses to extend default workflow.</p>
 | 
			
		||||
      <p>Create custom conversation statuses to extend default workflow.</p>
 | 
			
		||||
    </template>
 | 
			
		||||
  </AdminPageWithHelp>
 | 
			
		||||
</template>
 | 
			
		||||
 
 | 
			
		||||
@@ -32,7 +32,7 @@
 | 
			
		||||
    </template>
 | 
			
		||||
 | 
			
		||||
    <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>
 | 
			
		||||
  </AdminPageWithHelp>
 | 
			
		||||
</template>
 | 
			
		||||
 
 | 
			
		||||
@@ -6,7 +6,7 @@
 | 
			
		||||
 | 
			
		||||
    <template #help>
 | 
			
		||||
      <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>
 | 
			
		||||
  </AdminPageWithHelp>
 | 
			
		||||
</template>
 | 
			
		||||
 
 | 
			
		||||
@@ -35,7 +35,7 @@
 | 
			
		||||
 | 
			
		||||
    <template #help>
 | 
			
		||||
      <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>
 | 
			
		||||
  </AdminPageWithHelp>
 | 
			
		||||
</template>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <ConversationPlaceholder v-if="route.name === 'inbox'" />
 | 
			
		||||
  <ConversationPlaceholder v-if="['inbox', 'team-inbox', 'view-inbox'].includes(route.name)" />
 | 
			
		||||
  <router-view />
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -138,12 +138,14 @@ import { Card, CardContent, CardTitle } from '@/components/ui/card'
 | 
			
		||||
import { Input } from '@/components/ui/input'
 | 
			
		||||
import { Label } from '@/components/ui/label'
 | 
			
		||||
import { useEmitter } from '@/composables/useEmitter'
 | 
			
		||||
import { useUserStore } from '@/stores/user'
 | 
			
		||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
			
		||||
 | 
			
		||||
const emitter = useEmitter()
 | 
			
		||||
const errorMessage = ref('')
 | 
			
		||||
const isLoading = ref(false)
 | 
			
		||||
const router = useRouter()
 | 
			
		||||
const userStore = useUserStore()
 | 
			
		||||
const loginForm = ref({
 | 
			
		||||
  email: '',
 | 
			
		||||
  password: ''
 | 
			
		||||
@@ -207,7 +209,10 @@ const loginAction = () => {
 | 
			
		||||
      email: loginForm.value.email,
 | 
			
		||||
      password: loginForm.value.password
 | 
			
		||||
    })
 | 
			
		||||
    .then(() => {
 | 
			
		||||
    .then((resp) => {
 | 
			
		||||
      if (resp?.data?.data) {
 | 
			
		||||
        userStore.setCurrentUser(resp.data.data)
 | 
			
		||||
      }
 | 
			
		||||
      router.push({ name: 'inboxes' })
 | 
			
		||||
    })
 | 
			
		||||
    .catch((error) => {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,27 +1,29 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div
 | 
			
		||||
    class="overflow-y-auto p-4 pr-36"
 | 
			
		||||
    :class="{ 'opacity-50 transition-opacity duration-300': isLoading }"
 | 
			
		||||
  >
 | 
			
		||||
    <Spinner v-if="isLoading" />
 | 
			
		||||
    <div class="space-y-4">
 | 
			
		||||
      <div class="text-sm text-gray-500 text-right">
 | 
			
		||||
        Last updated: {{ new Date(lastUpdate).toLocaleTimeString() }}
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="mt-7 flex w-full space-x-4">
 | 
			
		||||
        <Card title="Open conversations" :counts="cardCounts" :labels="agentCountCardsLabels" />
 | 
			
		||||
        <Card
 | 
			
		||||
          class="w-8/12"
 | 
			
		||||
          title="Agent status"
 | 
			
		||||
          :counts="sampleAgentStatusCounts"
 | 
			
		||||
          :labels="sampleAgentStatusLabels"
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="rounded-lg box w-full p-5 bg-white">
 | 
			
		||||
        <LineChart :data="chartData.processedData"></LineChart>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="rounded-lg box w-full p-5 bg-white">
 | 
			
		||||
        <BarChart :data="chartData.status_summary"></BarChart>
 | 
			
		||||
  <div class="overflow-y-auto">
 | 
			
		||||
    <div
 | 
			
		||||
      class="p-4 w-[calc(100%-3rem)]"
 | 
			
		||||
      :class="{ 'opacity-50 transition-opacity duration-300': isLoading }"
 | 
			
		||||
    >
 | 
			
		||||
      <Spinner v-if="isLoading" />
 | 
			
		||||
      <div class="space-y-4">
 | 
			
		||||
        <div class="text-sm text-gray-500 text-right">
 | 
			
		||||
          Last updated: {{ new Date(lastUpdate).toLocaleTimeString() }}
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="mt-7 flex w-full space-x-4">
 | 
			
		||||
          <Card title="Open conversations" :counts="cardCounts" :labels="agentCountCardsLabels" />
 | 
			
		||||
          <Card
 | 
			
		||||
            class="w-8/12"
 | 
			
		||||
            title="Agent status"
 | 
			
		||||
            :counts="agentStatusCounts"
 | 
			
		||||
            :labels="agentStatusLabels"
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="rounded-lg box w-full p-5 bg-white">
 | 
			
		||||
          <LineChart :data="chartData.processedData"></LineChart>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="rounded-lg box w-full p-5 bg-white">
 | 
			
		||||
          <BarChart :data="chartData.status_summary"></BarChart>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
@@ -52,18 +54,18 @@ const agentCountCardsLabels = {
 | 
			
		||||
  pending: 'Pending'
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// TODO: Build agent status feature.
 | 
			
		||||
const sampleAgentStatusLabels = {
 | 
			
		||||
  online: 'Online',
 | 
			
		||||
  offline: 'Offline',
 | 
			
		||||
  away: 'Away'
 | 
			
		||||
}
 | 
			
		||||
const sampleAgentStatusCounts = {
 | 
			
		||||
  online: 5,
 | 
			
		||||
  offline: 2,
 | 
			
		||||
  away: 1
 | 
			
		||||
const agentStatusLabels = {
 | 
			
		||||
  agents_online: 'Online',
 | 
			
		||||
  agents_offline: 'Offline',
 | 
			
		||||
  agents_away: 'Away'
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const agentStatusCounts = ref({
 | 
			
		||||
  agents_online: 0,
 | 
			
		||||
  agents_offline: 0,
 | 
			
		||||
  agents_away: 0
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
onMounted(() => {
 | 
			
		||||
  getDashboardData()
 | 
			
		||||
  startRealtimeUpdates()
 | 
			
		||||
@@ -96,6 +98,11 @@ const getCardStats = async () => {
 | 
			
		||||
    .getOverviewCounts()
 | 
			
		||||
    .then((resp) => {
 | 
			
		||||
      cardCounts.value = resp.data.data
 | 
			
		||||
      agentStatusCounts.value = {
 | 
			
		||||
        agents_online: cardCounts.value.agents_online,
 | 
			
		||||
        agents_offline: cardCounts.value.agents_offline,
 | 
			
		||||
        agents_away: cardCounts.value.agents_away
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
    .catch((error) => {
 | 
			
		||||
      emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
			
		||||
 
 | 
			
		||||
@@ -121,8 +121,8 @@ export class WebSocketClient {
 | 
			
		||||
      if (this.socket?.readyState === WebSocket.OPEN) {
 | 
			
		||||
        try {
 | 
			
		||||
          this.socket.send('ping')
 | 
			
		||||
          if (Date.now() - this.lastPong > 10000) {
 | 
			
		||||
            console.warn('No pong received in 10 seconds, closing connection')
 | 
			
		||||
          if (Date.now() - this.lastPong > 60000) {
 | 
			
		||||
            console.warn('No pong received in 60 seconds, closing connection')
 | 
			
		||||
            this.socket.close()
 | 
			
		||||
          }
 | 
			
		||||
        } catch (e) {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,10 +1,16 @@
 | 
			
		||||
import { fileURLToPath, URL } from 'node:url'
 | 
			
		||||
 | 
			
		||||
import autoprefixer from 'autoprefixer'
 | 
			
		||||
import tailwind from 'tailwindcss'
 | 
			
		||||
import { defineConfig } from 'vite'
 | 
			
		||||
import vue from '@vitejs/plugin-vue'
 | 
			
		||||
 | 
			
		||||
// https://vitejs.dev/config/
 | 
			
		||||
export default defineConfig({
 | 
			
		||||
  css: {
 | 
			
		||||
    postcss: {
 | 
			
		||||
      plugins: [tailwind(), autoprefixer()],
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  server: {
 | 
			
		||||
    port: 8000,
 | 
			
		||||
    proxy: {
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										1
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										1
									
								
								go.mod
									
									
									
									
									
								
							@@ -35,6 +35,7 @@ require (
 | 
			
		||||
	github.com/zerodha/simplesessions/stores/redis/v3 v3.0.0
 | 
			
		||||
	github.com/zerodha/simplesessions/v3 v3.0.0
 | 
			
		||||
	golang.org/x/crypto v0.31.0
 | 
			
		||||
	golang.org/x/mod v0.17.0
 | 
			
		||||
	golang.org/x/oauth2 v0.21.0
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										2
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								go.sum
									
									
									
									
									
								
							@@ -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/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.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-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
 | 
			
		||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 | 
			
		||||
 
 | 
			
		||||
@@ -234,7 +234,10 @@ SELECT json_build_object(
 | 
			
		||||
    'open', COUNT(*),
 | 
			
		||||
    'awaiting_response', COUNT(CASE WHEN c.waiting_since IS NOT NULL THEN 1 END),
 | 
			
		||||
    'unassigned', COUNT(CASE WHEN c.assigned_user_id IS NULL THEN 1 END),
 | 
			
		||||
    'pending', COUNT(CASE WHEN c.first_reply_at IS NOT NULL THEN 1 END)
 | 
			
		||||
    'pending', COUNT(CASE WHEN c.first_reply_at IS NOT NULL THEN 1 END),
 | 
			
		||||
    'agents_online', (SELECT COUNT(*) FROM users WHERE availability_status = 'online' AND type = 'agent' AND deleted_at is null),
 | 
			
		||||
    'agents_away', (SELECT COUNT(*) FROM users WHERE availability_status in ('away', 'away_manual') AND type = 'agent' AND deleted_at is null),
 | 
			
		||||
    'agents_offline', (SELECT COUNT(*) FROM users WHERE availability_status = 'offline' AND type = 'agent' AND deleted_at is null)
 | 
			
		||||
)
 | 
			
		||||
FROM conversations c
 | 
			
		||||
INNER JOIN conversation_statuses s ON c.status_id = s.id
 | 
			
		||||
 
 | 
			
		||||
@@ -23,3 +23,14 @@ func IsUniqueViolationError(err error) bool {
 | 
			
		||||
	}
 | 
			
		||||
	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
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										22
									
								
								internal/migrations/v0.3.0.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								internal/migrations/v0.3.0.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,22 @@
 | 
			
		||||
package migrations
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"github.com/jmoiron/sqlx"
 | 
			
		||||
	"github.com/knadh/koanf/v2"
 | 
			
		||||
	"github.com/knadh/stuffbin"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// V0_3_0 updates the database schema to v0.3.0.
 | 
			
		||||
func V0_3_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
 | 
			
		||||
	_, err := db.Exec(`
 | 
			
		||||
	DO $$
 | 
			
		||||
	BEGIN
 | 
			
		||||
		IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_availability_status') THEN
 | 
			
		||||
			CREATE TYPE user_availability_status AS ENUM ('online', 'away', 'away_manual', 'offline');
 | 
			
		||||
		END IF;
 | 
			
		||||
	END$$;
 | 
			
		||||
	ALTER TABLE users ADD COLUMN IF NOT EXISTS availability_status user_availability_status DEFAULT 'offline' NOT NULL;
 | 
			
		||||
	ALTER TABLE users ADD COLUMN IF NOT EXISTS last_active_at TIMESTAMPTZ NULL;
 | 
			
		||||
	`)
 | 
			
		||||
	return err
 | 
			
		||||
}
 | 
			
		||||
@@ -115,6 +115,15 @@ func (u *Manager) Update(id int, r models.Role) error {
 | 
			
		||||
		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 {
 | 
			
		||||
		u.lo.Error("error updating role", "error", err)
 | 
			
		||||
		return envelope.NewError(envelope.GeneralError, "Error updating role", nil)
 | 
			
		||||
 
 | 
			
		||||
@@ -8,29 +8,37 @@ import (
 | 
			
		||||
	"github.com/volatiletech/null/v9"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var (
 | 
			
		||||
	Online     = "online"
 | 
			
		||||
	Offline    = "offline"
 | 
			
		||||
	Away       = "away"
 | 
			
		||||
	AwayManual = "away_manual"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type User struct {
 | 
			
		||||
	ID               int            `db:"id" json:"id,omitempty"`
 | 
			
		||||
	CreatedAt        time.Time      `db:"created_at" json:"created_at"`
 | 
			
		||||
	UpdatedAt        time.Time      `db:"updated_at" json:"updated_at"`
 | 
			
		||||
	FirstName        string         `db:"first_name" json:"first_name"`
 | 
			
		||||
	LastName         string         `db:"last_name" json:"last_name"`
 | 
			
		||||
	Email            null.String    `db:"email" json:"email,omitempty"`
 | 
			
		||||
	Type             string         `db:"type" json:"type"`
 | 
			
		||||
	PhoneNumber      null.String    `db:"phone_number" json:"phone_number,omitempty"`
 | 
			
		||||
	AvatarURL        null.String    `db:"avatar_url" json:"avatar_url"`
 | 
			
		||||
	Enabled          bool           `db:"enabled" json:"enabled"`
 | 
			
		||||
	Password         string         `db:"password" json:"-"`
 | 
			
		||||
	Roles            pq.StringArray `db:"roles" json:"roles,omitempty"`
 | 
			
		||||
	Permissions      pq.StringArray `db:"permissions" json:"permissions,omitempty"`
 | 
			
		||||
	Meta             pq.StringArray `db:"meta" json:"meta,omitempty"`
 | 
			
		||||
	CustomAttributes pq.StringArray `db:"custom_attributes" json:"custom_attributes,omitempty"`
 | 
			
		||||
	Teams            tmodels.Teams  `db:"teams" json:"teams,omitempty"`
 | 
			
		||||
	ContactChannelID int            `db:"contact_channel_id" json:"contact_channel_id,omitempty"`
 | 
			
		||||
	NewPassword      string         `db:"-" json:"new_password,omitempty"`
 | 
			
		||||
	SendWelcomeEmail bool           `db:"-" json:"send_welcome_email,omitempty"`
 | 
			
		||||
	InboxID          int            `json:"-"`
 | 
			
		||||
	SourceChannel    null.String    `json:"-"`
 | 
			
		||||
	SourceChannelID  null.String    `json:"-"`
 | 
			
		||||
	ID                 int            `db:"id" json:"id,omitempty"`
 | 
			
		||||
	CreatedAt          time.Time      `db:"created_at" json:"created_at"`
 | 
			
		||||
	UpdatedAt          time.Time      `db:"updated_at" json:"updated_at"`
 | 
			
		||||
	FirstName          string         `db:"first_name" json:"first_name"`
 | 
			
		||||
	LastName           string         `db:"last_name" json:"last_name"`
 | 
			
		||||
	Email              null.String    `db:"email" json:"email,omitempty"`
 | 
			
		||||
	Type               string         `db:"type" json:"type"`
 | 
			
		||||
	AvailabilityStatus string         `db:"availability_status" json:"availability_status"`
 | 
			
		||||
	PhoneNumber        null.String    `db:"phone_number" json:"phone_number,omitempty"`
 | 
			
		||||
	AvatarURL          null.String    `db:"avatar_url" json:"avatar_url"`
 | 
			
		||||
	Enabled            bool           `db:"enabled" json:"enabled"`
 | 
			
		||||
	Password           string         `db:"password" json:"-"`
 | 
			
		||||
	Roles              pq.StringArray `db:"roles" json:"roles,omitempty"`
 | 
			
		||||
	Permissions        pq.StringArray `db:"permissions" json:"permissions,omitempty"`
 | 
			
		||||
	Meta               pq.StringArray `db:"meta" json:"meta,omitempty"`
 | 
			
		||||
	CustomAttributes   pq.StringArray `db:"custom_attributes" json:"custom_attributes,omitempty"`
 | 
			
		||||
	Teams              tmodels.Teams  `db:"teams" json:"teams,omitempty"`
 | 
			
		||||
	ContactChannelID   int            `db:"contact_channel_id" json:"contact_channel_id,omitempty"`
 | 
			
		||||
	NewPassword        string         `db:"-" json:"new_password,omitempty"`
 | 
			
		||||
	SendWelcomeEmail   bool           `db:"-" json:"send_welcome_email,omitempty"`
 | 
			
		||||
	InboxID            int            `json:"-"`
 | 
			
		||||
	SourceChannel      null.String    `json:"-"`
 | 
			
		||||
	SourceChannelID    null.String    `json:"-"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (u *User) FullName() string {
 | 
			
		||||
 
 | 
			
		||||
@@ -20,41 +20,32 @@ SELECT email
 | 
			
		||||
FROM users
 | 
			
		||||
WHERE id = $1 AND deleted_at IS NULL AND type = 'agent';
 | 
			
		||||
 | 
			
		||||
-- name: get-user-by-email
 | 
			
		||||
SELECT u.id, u.email, u.password, u.avatar_url, u.first_name, u.last_name, u.enabled,
 | 
			
		||||
      array_agg(DISTINCT r.name) as roles,
 | 
			
		||||
      array_agg(DISTINCT p) as permissions
 | 
			
		||||
FROM users u
 | 
			
		||||
JOIN user_roles ur ON ur.user_id = u.id 
 | 
			
		||||
JOIN roles r ON r.id = ur.role_id,
 | 
			
		||||
    unnest(r.permissions) p
 | 
			
		||||
WHERE u.email = $1 AND u.deleted_at IS NULL AND u.type = 'agent'
 | 
			
		||||
GROUP BY u.id;
 | 
			
		||||
 | 
			
		||||
-- name: get-user
 | 
			
		||||
SELECT
 | 
			
		||||
   u.id,
 | 
			
		||||
   u.created_at,
 | 
			
		||||
   u.updated_at,
 | 
			
		||||
   u.enabled,
 | 
			
		||||
   u.email,
 | 
			
		||||
   u.avatar_url,
 | 
			
		||||
   u.first_name,
 | 
			
		||||
   u.last_name,
 | 
			
		||||
   array_agg(DISTINCT r.name) as roles,
 | 
			
		||||
   COALESCE(
 | 
			
		||||
       (SELECT json_agg(json_build_object('id', t.id, 'name', t.name, 'emoji', t.emoji))
 | 
			
		||||
        FROM team_members tm
 | 
			
		||||
        JOIN teams t ON tm.team_id = t.id
 | 
			
		||||
        WHERE tm.user_id = u.id),
 | 
			
		||||
       '[]'
 | 
			
		||||
   ) AS teams,
 | 
			
		||||
   array_agg(DISTINCT p) as permissions
 | 
			
		||||
    u.id,
 | 
			
		||||
    u.email,
 | 
			
		||||
    u.password,
 | 
			
		||||
    u.created_at,
 | 
			
		||||
    u.updated_at,
 | 
			
		||||
    u.enabled,
 | 
			
		||||
    u.avatar_url,
 | 
			
		||||
    u.first_name,
 | 
			
		||||
    u.last_name,
 | 
			
		||||
    u.availability_status,
 | 
			
		||||
    array_agg(DISTINCT r.name) as roles,
 | 
			
		||||
    COALESCE(
 | 
			
		||||
         (SELECT json_agg(json_build_object('id', t.id, 'name', t.name, 'emoji', t.emoji))
 | 
			
		||||
          FROM team_members tm
 | 
			
		||||
          JOIN teams t ON tm.team_id = t.id
 | 
			
		||||
          WHERE tm.user_id = u.id),
 | 
			
		||||
         '[]'
 | 
			
		||||
    ) AS teams,
 | 
			
		||||
    array_agg(DISTINCT p) as permissions
 | 
			
		||||
FROM users u
 | 
			
		||||
LEFT JOIN user_roles ur ON ur.user_id = u.id
 | 
			
		||||
LEFT JOIN roles r ON r.id = ur.role_id,
 | 
			
		||||
    unnest(r.permissions) p
 | 
			
		||||
WHERE u.id = $1 AND u.deleted_at IS NULL AND u.type = 'agent'
 | 
			
		||||
     unnest(r.permissions) p
 | 
			
		||||
WHERE (u.id = $1 OR u.email = $2) AND u.deleted_at IS NULL AND u.type = 'agent'
 | 
			
		||||
GROUP BY u.id;
 | 
			
		||||
 | 
			
		||||
-- name: set-user-password
 | 
			
		||||
@@ -92,6 +83,22 @@ UPDATE users
 | 
			
		||||
SET avatar_url = $2, updated_at = now()
 | 
			
		||||
WHERE id = $1 AND type = 'agent';
 | 
			
		||||
 | 
			
		||||
-- name: update-availability
 | 
			
		||||
UPDATE users
 | 
			
		||||
SET availability_status = $2
 | 
			
		||||
WHERE id = $1;
 | 
			
		||||
 | 
			
		||||
-- name: update-last-active-at
 | 
			
		||||
UPDATE users
 | 
			
		||||
SET last_active_at = now(),
 | 
			
		||||
availability_status = CASE WHEN availability_status = 'offline' THEN 'online' ELSE availability_status END
 | 
			
		||||
WHERE id = $1;
 | 
			
		||||
 | 
			
		||||
-- name: update-inactive-offline
 | 
			
		||||
UPDATE users
 | 
			
		||||
SET availability_status = 'offline'
 | 
			
		||||
WHERE last_active_at < now() - interval '5 minutes' and availability_status != 'offline';
 | 
			
		||||
 | 
			
		||||
-- name: get-permissions
 | 
			
		||||
SELECT DISTINCT unnest(r.permissions)
 | 
			
		||||
FROM users u
 | 
			
		||||
 
 | 
			
		||||
@@ -10,6 +10,7 @@ import (
 | 
			
		||||
	"os"
 | 
			
		||||
	"regexp"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"log"
 | 
			
		||||
 | 
			
		||||
@@ -28,8 +29,8 @@ import (
 | 
			
		||||
 | 
			
		||||
const (
 | 
			
		||||
	systemUserEmail       = "System"
 | 
			
		||||
	minSystemUserPassword = 8
 | 
			
		||||
	maxSystemUserPassword = 50
 | 
			
		||||
	minSystemUserPassword = 10
 | 
			
		||||
	maxSystemUserPassword = 72
 | 
			
		||||
	UserTypeAgent         = "agent"
 | 
			
		||||
	UserTypeContact       = "contact"
 | 
			
		||||
)
 | 
			
		||||
@@ -42,7 +43,7 @@ var (
 | 
			
		||||
	// GenerateFromPassword is too long (i.e. > 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.
 | 
			
		||||
@@ -61,13 +62,15 @@ type Opts struct {
 | 
			
		||||
// queries contains prepared SQL queries.
 | 
			
		||||
type queries struct {
 | 
			
		||||
	GetUsers              *sqlx.Stmt `query:"get-users"`
 | 
			
		||||
	GetUserCompact        *sqlx.Stmt `query:"get-users-compact"`
 | 
			
		||||
	GetUsersCompact       *sqlx.Stmt `query:"get-users-compact"`
 | 
			
		||||
	GetUser               *sqlx.Stmt `query:"get-user"`
 | 
			
		||||
	GetEmail              *sqlx.Stmt `query:"get-email"`
 | 
			
		||||
	GetPermissions        *sqlx.Stmt `query:"get-permissions"`
 | 
			
		||||
	GetUserByEmail        *sqlx.Stmt `query:"get-user-by-email"`
 | 
			
		||||
	UpdateUser            *sqlx.Stmt `query:"update-user"`
 | 
			
		||||
	UpdateAvatar          *sqlx.Stmt `query:"update-avatar"`
 | 
			
		||||
	UpdateAvailability    *sqlx.Stmt `query:"update-availability"`
 | 
			
		||||
	UpdateLastActiveAt    *sqlx.Stmt `query:"update-last-active-at"`
 | 
			
		||||
	UpdateInactiveOffline *sqlx.Stmt `query:"update-inactive-offline"`
 | 
			
		||||
	SoftDeleteUser        *sqlx.Stmt `query:"soft-delete-user"`
 | 
			
		||||
	SetUserPassword       *sqlx.Stmt `query:"set-user-password"`
 | 
			
		||||
	SetResetPasswordToken *sqlx.Stmt `query:"set-reset-password-token"`
 | 
			
		||||
@@ -89,22 +92,19 @@ func New(i18n *i18n.I18n, opts Opts) (*Manager, error) {
 | 
			
		||||
	}, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// VerifyPassword authenticates a user by email and password.
 | 
			
		||||
// VerifyPassword authenticates an user by email and password.
 | 
			
		||||
func (u *Manager) VerifyPassword(email string, password []byte) (models.User, error) {
 | 
			
		||||
	var user models.User
 | 
			
		||||
 | 
			
		||||
	if err := u.q.GetUserByEmail.Get(&user, email); err != nil {
 | 
			
		||||
	if err := u.q.GetUser.Get(&user, 0, email); err != nil {
 | 
			
		||||
		if errors.Is(err, sql.ErrNoRows) {
 | 
			
		||||
			return user, envelope.NewError(envelope.InputError, u.i18n.T("user.invalidEmailPassword"), nil)
 | 
			
		||||
		}
 | 
			
		||||
		u.lo.Error("error fetching user from db", "error", err)
 | 
			
		||||
		return user, envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorFetching", "name", "{globals.entities.user}"), nil)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := u.verifyPassword(password, user.Password); err != nil {
 | 
			
		||||
		return user, envelope.NewError(envelope.InputError, u.i18n.T("user.invalidEmailPassword"), nil)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return user, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -125,7 +125,7 @@ func (u *Manager) GetAll() ([]models.User, error) {
 | 
			
		||||
// GetAllCompact returns a compact list of users with limited fields.
 | 
			
		||||
func (u *Manager) GetAllCompact() ([]models.User, error) {
 | 
			
		||||
	var users = make([]models.User, 0)
 | 
			
		||||
	if err := u.q.GetUserCompact.Select(&users); err != nil {
 | 
			
		||||
	if err := u.q.GetUsersCompact.Select(&users); err != nil {
 | 
			
		||||
		if errors.Is(err, sql.ErrNoRows) {
 | 
			
		||||
			return users, nil
 | 
			
		||||
		}
 | 
			
		||||
@@ -154,10 +154,10 @@ func (u *Manager) CreateAgent(user *models.User) error {
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Get retrieves a user by ID.
 | 
			
		||||
// Get retrieves an user by ID.
 | 
			
		||||
func (u *Manager) Get(id int) (models.User, error) {
 | 
			
		||||
	var user models.User
 | 
			
		||||
	if err := u.q.GetUser.Get(&user, id); err != nil {
 | 
			
		||||
	if err := u.q.GetUser.Get(&user, id, ""); err != nil {
 | 
			
		||||
		if errors.Is(err, sql.ErrNoRows) {
 | 
			
		||||
			u.lo.Error("user not found", "id", id, "error", err)
 | 
			
		||||
			return user, envelope.NewError(envelope.GeneralError, "User not found", nil)
 | 
			
		||||
@@ -168,10 +168,10 @@ func (u *Manager) Get(id int) (models.User, error) {
 | 
			
		||||
	return user, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetByEmail retrieves a user by email
 | 
			
		||||
// GetByEmail retrieves an user by email
 | 
			
		||||
func (u *Manager) GetByEmail(email string) (models.User, error) {
 | 
			
		||||
	var user models.User
 | 
			
		||||
	if err := u.q.GetUserByEmail.Get(&user, email); err != nil {
 | 
			
		||||
	if err := u.q.GetUser.Get(&user, 0, email); err != nil {
 | 
			
		||||
		if errors.Is(err, sql.ErrNoRows) {
 | 
			
		||||
			return user, envelope.NewError(envelope.GeneralError, "User not found", nil)
 | 
			
		||||
		}
 | 
			
		||||
@@ -195,10 +195,10 @@ func (u *Manager) UpdateAvatar(id int, avatar string) error {
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Update updates a user.
 | 
			
		||||
// Update updates an user.
 | 
			
		||||
func (u *Manager) Update(id int, user models.User) error {
 | 
			
		||||
	var (
 | 
			
		||||
		hashedPassword interface{}
 | 
			
		||||
		hashedPassword any
 | 
			
		||||
		err            error
 | 
			
		||||
	)
 | 
			
		||||
 | 
			
		||||
@@ -221,7 +221,7 @@ func (u *Manager) Update(id int, user models.User) error {
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// SoftDelete soft deletes a user.
 | 
			
		||||
// SoftDelete soft deletes an user.
 | 
			
		||||
func (u *Manager) SoftDelete(id int) error {
 | 
			
		||||
	// Disallow if user is system user.
 | 
			
		||||
	systemUser, err := u.GetSystemUser()
 | 
			
		||||
@@ -239,7 +239,7 @@ func (u *Manager) SoftDelete(id int) error {
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetEmail retrieves the email of a user by ID.
 | 
			
		||||
// GetEmail retrieves the email of an user by ID.
 | 
			
		||||
func (u *Manager) GetEmail(id int) (string, error) {
 | 
			
		||||
	var email string
 | 
			
		||||
	if err := u.q.GetEmail.Get(&email, id); err != nil {
 | 
			
		||||
@@ -252,7 +252,7 @@ func (u *Manager) GetEmail(id int) (string, error) {
 | 
			
		||||
	return email, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// SetResetPasswordToken sets a reset password token for a user and returns the token.
 | 
			
		||||
// SetResetPasswordToken sets a reset password token for an user and returns the token.
 | 
			
		||||
func (u *Manager) SetResetPasswordToken(id int) (string, error) {
 | 
			
		||||
	token, err := stringutil.RandomAlphanumeric(32)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
@@ -266,10 +266,10 @@ func (u *Manager) SetResetPasswordToken(id int) (string, error) {
 | 
			
		||||
	return token, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ResetPassword sets a new password for a user.
 | 
			
		||||
// ResetPassword sets a new password for an user.
 | 
			
		||||
func (u *Manager) ResetPassword(token, password string) error {
 | 
			
		||||
	if !u.isStrongPassword(password) {
 | 
			
		||||
		return envelope.NewError(envelope.InputError, "Password is not strong enough, " + SystemUserPasswordHint, nil)
 | 
			
		||||
		return envelope.NewError(envelope.InputError, "Password is not strong enough, "+SystemUserPasswordHint, nil)
 | 
			
		||||
	}
 | 
			
		||||
	// Hash password.
 | 
			
		||||
	passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
 | 
			
		||||
@@ -284,7 +284,7 @@ func (u *Manager) ResetPassword(token, password string) error {
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetPermissions retrieves the permissions of a user by ID.
 | 
			
		||||
// GetPermissions retrieves the permissions of an user by ID.
 | 
			
		||||
func (u *Manager) GetPermissions(id int) ([]string, error) {
 | 
			
		||||
	var permissions []string
 | 
			
		||||
	if err := u.q.GetPermissions.Select(&permissions, id); err != nil {
 | 
			
		||||
@@ -294,6 +294,52 @@ func (u *Manager) GetPermissions(id int) ([]string, error) {
 | 
			
		||||
	return permissions, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// UpdateAvailability updates the availability status of an user.
 | 
			
		||||
func (u *Manager) UpdateAvailability(id int, status string) error {
 | 
			
		||||
	if _, err := u.q.UpdateAvailability.Exec(id, status); err != nil {
 | 
			
		||||
		u.lo.Error("error updating user availability", "error", err)
 | 
			
		||||
		return envelope.NewError(envelope.GeneralError, "Error updating user availability", nil)
 | 
			
		||||
	}
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// UpdateLastActive updates the last active timestamp of an user.
 | 
			
		||||
func (u *Manager) UpdateLastActive(id int) error {
 | 
			
		||||
	if _, err := u.q.UpdateLastActiveAt.Exec(id); err != nil {
 | 
			
		||||
		u.lo.Error("error updating user last active at", "error", err)
 | 
			
		||||
		return envelope.NewError(envelope.GeneralError, "Error updating user last active at", nil)
 | 
			
		||||
	}
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// MonitorAgentAvailability continuously checks for user activity and sets them offline if inactive for more than 5 minutes.
 | 
			
		||||
func (u *Manager) MonitorAgentAvailability(ctx context.Context) {
 | 
			
		||||
	ticker := time.NewTicker(30 * time.Second)
 | 
			
		||||
	defer ticker.Stop()
 | 
			
		||||
	for {
 | 
			
		||||
		select {
 | 
			
		||||
		case <-ticker.C:
 | 
			
		||||
			u.markInactiveAgentsOffline()
 | 
			
		||||
		case <-ctx.Done():
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// markInactiveAgentsOffline sets agents offline if they have been inactive for more than 5 minutes.
 | 
			
		||||
func (u *Manager) markInactiveAgentsOffline() {
 | 
			
		||||
	u.lo.Debug("marking inactive agents offline")
 | 
			
		||||
	if res, err := u.q.UpdateInactiveOffline.Exec(); err != nil {
 | 
			
		||||
		u.lo.Error("error setting users offline", "error", err)
 | 
			
		||||
	} else {
 | 
			
		||||
		rows, _ := res.RowsAffected()
 | 
			
		||||
		if rows > 0 {
 | 
			
		||||
			u.lo.Info("set inactive users offline", "count", rows)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	u.lo.Debug("marked inactive agents offline")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// verifyPassword compares the provided password with the stored password hash.
 | 
			
		||||
func (u *Manager) verifyPassword(pwd []byte, pwdHash string) error {
 | 
			
		||||
	if err := bcrypt.CompareHashAndPassword([]byte(pwdHash), pwd); err != nil {
 | 
			
		||||
@@ -335,7 +381,7 @@ func ChangeSystemUserPassword(ctx context.Context, db *sqlx.DB) error {
 | 
			
		||||
	if err := updateSystemUserPassword(db, hashedPassword); err != nil {
 | 
			
		||||
		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
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -382,8 +428,11 @@ func IsStrongSystemUserPassword(password string) bool {
 | 
			
		||||
		return false
 | 
			
		||||
	}
 | 
			
		||||
	hasUppercase := regexp.MustCompile(`[A-Z]`).MatchString(password)
 | 
			
		||||
	hasLowercase := regexp.MustCompile(`[a-z]`).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.
 | 
			
		||||
 
 | 
			
		||||
@@ -94,7 +94,9 @@ func (c *Client) Listen() {
 | 
			
		||||
 | 
			
		||||
// processIncomingMessage processes incoming messages from the client.
 | 
			
		||||
func (c *Client) processIncomingMessage(data []byte) {
 | 
			
		||||
	// Handle ping messages, and update last active time for user.
 | 
			
		||||
	if string(data) == "ping" {
 | 
			
		||||
		c.Hub.userStore.UpdateLastActive(c.ID)
 | 
			
		||||
		c.SendMessage([]byte("pong"), websocket.TextMessage)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 
 | 
			
		||||
@@ -13,13 +13,20 @@ type Hub struct {
 | 
			
		||||
	// Client ID to WS Client map, user can connect from multiple devices and each device will have a separate client.
 | 
			
		||||
	clients      map[int][]*Client
 | 
			
		||||
	clientsMutex sync.Mutex
 | 
			
		||||
 | 
			
		||||
	userStore userStore
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type userStore interface {
 | 
			
		||||
	UpdateLastActive(userID int) error
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// NewHub creates a new websocket hub.
 | 
			
		||||
func NewHub() *Hub {
 | 
			
		||||
func NewHub(userStore userStore) *Hub {
 | 
			
		||||
	return &Hub{
 | 
			
		||||
		clients:      make(map[int][]*Client, 10000),
 | 
			
		||||
		clientsMutex: sync.Mutex{},
 | 
			
		||||
		userStore:    userStore,
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -13,6 +13,7 @@ DROP TYPE IF EXISTS "automation_execution_mode" CASCADE; CREATE TYPE "automation
 | 
			
		||||
DROP TYPE IF EXISTS "macro_visibility" CASCADE; CREATE TYPE "macro_visibility" AS ENUM ('all', 'team', 'user');
 | 
			
		||||
DROP TYPE IF EXISTS "media_disposition" CASCADE; CREATE TYPE "media_disposition" AS ENUM ('inline', 'attachment');
 | 
			
		||||
DROP TYPE IF EXISTS "media_store" CASCADE; CREATE TYPE "media_store" AS ENUM ('s3', 'fs');
 | 
			
		||||
DROP TYPE IF EXISTS "user_availability_status" CASCADE; CREATE TYPE "user_availability_status" AS ENUM ('online', 'away', 'away_manual', 'offline');
 | 
			
		||||
 | 
			
		||||
-- Sequence to generate reference number for conversations.
 | 
			
		||||
DROP SEQUENCE IF EXISTS conversation_reference_number_sequence; CREATE SEQUENCE conversation_reference_number_sequence START 100;
 | 
			
		||||
@@ -118,6 +119,8 @@ CREATE TABLE users (
 | 
			
		||||
	custom_attributes JSONB DEFAULT '{}'::jsonb NOT NULL,
 | 
			
		||||
    reset_password_token TEXT NULL,
 | 
			
		||||
    reset_password_token_expiry TIMESTAMPTZ NULL,
 | 
			
		||||
	availability_status user_availability_status DEFAULT 'offline' NOT NULL,
 | 
			
		||||
	last_active_at TIMESTAMPTZ NULL,
 | 
			
		||||
    CONSTRAINT constraint_users_on_country CHECK (LENGTH(country) <= 140),
 | 
			
		||||
    CONSTRAINT constraint_users_on_phone_number CHECK (LENGTH(phone_number) <= 20),
 | 
			
		||||
    CONSTRAINT constraint_users_on_email_length CHECK (LENGTH(email) <= 320),
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user