mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-11-03 05:23:48 +00:00
Compare commits
17 Commits
v0.1.1-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 |
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
VERSION export-subst
|
||||
@@ -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..."
|
||||
CGO_ENABLED=0 go run -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.frontendDir=frontend/dist'" 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}' -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.
|
||||
|
||||
@@ -47,8 +47,9 @@ And more checkout - [libredesk.io](https://libredesk.io)
|
||||
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)
|
||||
|
||||
```shell
|
||||
# Download the compose file to the current directory.
|
||||
curl -LO https://github.com/abhinavxd/libredesk/raw/master/docker-compose.yml
|
||||
# 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
|
||||
|
||||
# Copy the config.sample.toml to config.toml and edit it as needed.
|
||||
cp config.sample.toml config.toml
|
||||
|
||||
@@ -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,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]
|
||||
@@ -45,7 +46,7 @@ max_lifetime = "300s"
|
||||
|
||||
# Redis.
|
||||
[redis]
|
||||
# If using docker compose, use the service name as the host. e.g. redis
|
||||
# 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:
|
||||
|
||||
@@ -21,7 +21,7 @@ git clone https://github.com/abhinavxd/libredesk.git
|
||||
|
||||
### Running the Dev Environment
|
||||
|
||||
1. Run `make run` to start the libredesk backend dev server on `:9000`.
|
||||
1. Run `make run-backend` to start the libredesk backend dev server on `:9000`.
|
||||
2. Run `make run-frontend` to start the Vue frontend in dev mode using pnpm on `:8000`. Requests are proxied to the backend running on `:9000` check `vite.config.js` for the proxy config.
|
||||
|
||||
---
|
||||
|
||||
@@ -15,13 +15,14 @@ Libredesk is a single binary application that requires postgres and redis to run
|
||||
|
||||
## Docker
|
||||
|
||||
The latest image is available on DockerHub at `libredesk/llibredeskistmonk:latest`
|
||||
The latest image is available on DockerHub at `libredesk/libredesk:latest`
|
||||
|
||||
The recommended method is to download the [docker-compose.yml](https://github.com/abhinavxd/libredesk/blob/master/docker-compose.yml) file, customize it for your environment and then to simply run `docker compose up -d`.
|
||||
The recommended method is to download the [docker-compose.yml](https://github.com/abhinavxd/libredesk/blob/main/docker-compose.yml) file, customize it for your environment and then to simply run `docker compose up -d`.
|
||||
|
||||
```shell
|
||||
# Download the compose file to the current directory.
|
||||
curl -LO https://github.com/abhinavxd/libredesk/raw/master/docker-compose.yml
|
||||
# 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
|
||||
@@ -40,7 +41,7 @@ Go to `http://localhost:9000` and login with the email `System` and the password
|
||||
|
||||
## Compiling from source
|
||||
|
||||
To compile the latest unreleased version (`master` branch):
|
||||
To compile the latest unreleased version (`main` branch):
|
||||
|
||||
1. Make sure `go`, `nodejs`, and `pnpm` are installed on your system.
|
||||
2. `git clone git@github.com:abhinavxd/libredesk.git`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "libredesk",
|
||||
"version": "0.0.0",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -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,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)
|
||||
}
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
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
|
||||
}
|
||||
@@ -8,29 +8,37 @@ import (
|
||||
"github.com/volatiletech/null/v9"
|
||||
)
|
||||
|
||||
var (
|
||||
Online = "online"
|
||||
Offline = "offline"
|
||||
Away = "away"
|
||||
AwayManual = "away_manual"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID int `db:"id" json:"id,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
FirstName string `db:"first_name" json:"first_name"`
|
||||
LastName string `db:"last_name" json:"last_name"`
|
||||
Email null.String `db:"email" json:"email,omitempty"`
|
||||
Type string `db:"type" json:"type"`
|
||||
PhoneNumber null.String `db:"phone_number" json:"phone_number,omitempty"`
|
||||
AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
|
||||
Enabled bool `db:"enabled" json:"enabled"`
|
||||
Password string `db:"password" json:"-"`
|
||||
Roles pq.StringArray `db:"roles" json:"roles,omitempty"`
|
||||
Permissions pq.StringArray `db:"permissions" json:"permissions,omitempty"`
|
||||
Meta pq.StringArray `db:"meta" json:"meta,omitempty"`
|
||||
CustomAttributes pq.StringArray `db:"custom_attributes" json:"custom_attributes,omitempty"`
|
||||
Teams tmodels.Teams `db:"teams" json:"teams,omitempty"`
|
||||
ContactChannelID int `db:"contact_channel_id" json:"contact_channel_id,omitempty"`
|
||||
NewPassword string `db:"-" json:"new_password,omitempty"`
|
||||
SendWelcomeEmail bool `db:"-" json:"send_welcome_email,omitempty"`
|
||||
InboxID int `json:"-"`
|
||||
SourceChannel null.String `json:"-"`
|
||||
SourceChannelID null.String `json:"-"`
|
||||
ID int `db:"id" json:"id,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
FirstName string `db:"first_name" json:"first_name"`
|
||||
LastName string `db:"last_name" json:"last_name"`
|
||||
Email null.String `db:"email" json:"email,omitempty"`
|
||||
Type string `db:"type" json:"type"`
|
||||
AvailabilityStatus string `db:"availability_status" json:"availability_status"`
|
||||
PhoneNumber null.String `db:"phone_number" json:"phone_number,omitempty"`
|
||||
AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
|
||||
Enabled bool `db:"enabled" json:"enabled"`
|
||||
Password string `db:"password" json:"-"`
|
||||
Roles pq.StringArray `db:"roles" json:"roles,omitempty"`
|
||||
Permissions pq.StringArray `db:"permissions" json:"permissions,omitempty"`
|
||||
Meta pq.StringArray `db:"meta" json:"meta,omitempty"`
|
||||
CustomAttributes pq.StringArray `db:"custom_attributes" json:"custom_attributes,omitempty"`
|
||||
Teams tmodels.Teams `db:"teams" json:"teams,omitempty"`
|
||||
ContactChannelID int `db:"contact_channel_id" json:"contact_channel_id,omitempty"`
|
||||
NewPassword string `db:"-" json:"new_password,omitempty"`
|
||||
SendWelcomeEmail bool `db:"-" json:"send_welcome_email,omitempty"`
|
||||
InboxID int `json:"-"`
|
||||
SourceChannel null.String `json:"-"`
|
||||
SourceChannelID null.String `json:"-"`
|
||||
}
|
||||
|
||||
func (u *User) FullName() string {
|
||||
|
||||
@@ -20,41 +20,32 @@ SELECT email
|
||||
FROM users
|
||||
WHERE id = $1 AND deleted_at IS NULL AND type = 'agent';
|
||||
|
||||
-- name: get-user-by-email
|
||||
SELECT u.id, u.email, u.password, u.avatar_url, u.first_name, u.last_name, u.enabled,
|
||||
array_agg(DISTINCT r.name) as roles,
|
||||
array_agg(DISTINCT p) as permissions
|
||||
FROM users u
|
||||
JOIN user_roles ur ON ur.user_id = u.id
|
||||
JOIN roles r ON r.id = ur.role_id,
|
||||
unnest(r.permissions) p
|
||||
WHERE u.email = $1 AND u.deleted_at IS NULL AND u.type = 'agent'
|
||||
GROUP BY u.id;
|
||||
|
||||
-- name: get-user
|
||||
SELECT
|
||||
u.id,
|
||||
u.created_at,
|
||||
u.updated_at,
|
||||
u.enabled,
|
||||
u.email,
|
||||
u.avatar_url,
|
||||
u.first_name,
|
||||
u.last_name,
|
||||
array_agg(DISTINCT r.name) as roles,
|
||||
COALESCE(
|
||||
(SELECT json_agg(json_build_object('id', t.id, 'name', t.name, 'emoji', t.emoji))
|
||||
FROM team_members tm
|
||||
JOIN teams t ON tm.team_id = t.id
|
||||
WHERE tm.user_id = u.id),
|
||||
'[]'
|
||||
) AS teams,
|
||||
array_agg(DISTINCT p) as permissions
|
||||
u.id,
|
||||
u.email,
|
||||
u.password,
|
||||
u.created_at,
|
||||
u.updated_at,
|
||||
u.enabled,
|
||||
u.avatar_url,
|
||||
u.first_name,
|
||||
u.last_name,
|
||||
u.availability_status,
|
||||
array_agg(DISTINCT r.name) as roles,
|
||||
COALESCE(
|
||||
(SELECT json_agg(json_build_object('id', t.id, 'name', t.name, 'emoji', t.emoji))
|
||||
FROM team_members tm
|
||||
JOIN teams t ON tm.team_id = t.id
|
||||
WHERE tm.user_id = u.id),
|
||||
'[]'
|
||||
) AS teams,
|
||||
array_agg(DISTINCT p) as permissions
|
||||
FROM users u
|
||||
LEFT JOIN user_roles ur ON ur.user_id = u.id
|
||||
LEFT JOIN roles r ON r.id = ur.role_id,
|
||||
unnest(r.permissions) p
|
||||
WHERE u.id = $1 AND u.deleted_at IS NULL AND u.type = 'agent'
|
||||
unnest(r.permissions) p
|
||||
WHERE (u.id = $1 OR u.email = $2) AND u.deleted_at IS NULL AND u.type = 'agent'
|
||||
GROUP BY u.id;
|
||||
|
||||
-- name: set-user-password
|
||||
@@ -92,6 +83,22 @@ UPDATE users
|
||||
SET avatar_url = $2, updated_at = now()
|
||||
WHERE id = $1 AND type = 'agent';
|
||||
|
||||
-- name: update-availability
|
||||
UPDATE users
|
||||
SET availability_status = $2
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: update-last-active-at
|
||||
UPDATE users
|
||||
SET last_active_at = now(),
|
||||
availability_status = CASE WHEN availability_status = 'offline' THEN 'online' ELSE availability_status END
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: update-inactive-offline
|
||||
UPDATE users
|
||||
SET availability_status = 'offline'
|
||||
WHERE last_active_at < now() - interval '5 minutes' and availability_status != 'offline';
|
||||
|
||||
-- name: get-permissions
|
||||
SELECT DISTINCT unnest(r.permissions)
|
||||
FROM users u
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"log"
|
||||
|
||||
@@ -61,13 +62,15 @@ type Opts struct {
|
||||
// queries contains prepared SQL queries.
|
||||
type queries struct {
|
||||
GetUsers *sqlx.Stmt `query:"get-users"`
|
||||
GetUserCompact *sqlx.Stmt `query:"get-users-compact"`
|
||||
GetUsersCompact *sqlx.Stmt `query:"get-users-compact"`
|
||||
GetUser *sqlx.Stmt `query:"get-user"`
|
||||
GetEmail *sqlx.Stmt `query:"get-email"`
|
||||
GetPermissions *sqlx.Stmt `query:"get-permissions"`
|
||||
GetUserByEmail *sqlx.Stmt `query:"get-user-by-email"`
|
||||
UpdateUser *sqlx.Stmt `query:"update-user"`
|
||||
UpdateAvatar *sqlx.Stmt `query:"update-avatar"`
|
||||
UpdateAvailability *sqlx.Stmt `query:"update-availability"`
|
||||
UpdateLastActiveAt *sqlx.Stmt `query:"update-last-active-at"`
|
||||
UpdateInactiveOffline *sqlx.Stmt `query:"update-inactive-offline"`
|
||||
SoftDeleteUser *sqlx.Stmt `query:"soft-delete-user"`
|
||||
SetUserPassword *sqlx.Stmt `query:"set-user-password"`
|
||||
SetResetPasswordToken *sqlx.Stmt `query:"set-reset-password-token"`
|
||||
@@ -89,22 +92,19 @@ func New(i18n *i18n.I18n, opts Opts) (*Manager, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// VerifyPassword authenticates a user by email and password.
|
||||
// VerifyPassword authenticates an user by email and password.
|
||||
func (u *Manager) VerifyPassword(email string, password []byte) (models.User, error) {
|
||||
var user models.User
|
||||
|
||||
if err := u.q.GetUserByEmail.Get(&user, email); err != nil {
|
||||
if err := u.q.GetUser.Get(&user, 0, email); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return user, envelope.NewError(envelope.InputError, u.i18n.T("user.invalidEmailPassword"), nil)
|
||||
}
|
||||
u.lo.Error("error fetching user from db", "error", err)
|
||||
return user, envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorFetching", "name", "{globals.entities.user}"), nil)
|
||||
}
|
||||
|
||||
if err := u.verifyPassword(password, user.Password); err != nil {
|
||||
return user, envelope.NewError(envelope.InputError, u.i18n.T("user.invalidEmailPassword"), nil)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ func (u *Manager) GetAll() ([]models.User, error) {
|
||||
// GetAllCompact returns a compact list of users with limited fields.
|
||||
func (u *Manager) GetAllCompact() ([]models.User, error) {
|
||||
var users = make([]models.User, 0)
|
||||
if err := u.q.GetUserCompact.Select(&users); err != nil {
|
||||
if err := u.q.GetUsersCompact.Select(&users); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return users, nil
|
||||
}
|
||||
@@ -154,10 +154,10 @@ func (u *Manager) CreateAgent(user *models.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get retrieves a user by ID.
|
||||
// Get retrieves an user by ID.
|
||||
func (u *Manager) Get(id int) (models.User, error) {
|
||||
var user models.User
|
||||
if err := u.q.GetUser.Get(&user, id); err != nil {
|
||||
if err := u.q.GetUser.Get(&user, id, ""); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
u.lo.Error("user not found", "id", id, "error", err)
|
||||
return user, envelope.NewError(envelope.GeneralError, "User not found", nil)
|
||||
@@ -168,10 +168,10 @@ func (u *Manager) Get(id int) (models.User, error) {
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// GetByEmail retrieves a user by email
|
||||
// GetByEmail retrieves an user by email
|
||||
func (u *Manager) GetByEmail(email string) (models.User, error) {
|
||||
var user models.User
|
||||
if err := u.q.GetUserByEmail.Get(&user, email); err != nil {
|
||||
if err := u.q.GetUser.Get(&user, 0, email); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return user, envelope.NewError(envelope.GeneralError, "User not found", nil)
|
||||
}
|
||||
@@ -195,10 +195,10 @@ func (u *Manager) UpdateAvatar(id int, avatar string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update updates a user.
|
||||
// Update updates an user.
|
||||
func (u *Manager) Update(id int, user models.User) error {
|
||||
var (
|
||||
hashedPassword interface{}
|
||||
hashedPassword any
|
||||
err error
|
||||
)
|
||||
|
||||
@@ -221,7 +221,7 @@ func (u *Manager) Update(id int, user models.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SoftDelete soft deletes a user.
|
||||
// SoftDelete soft deletes an user.
|
||||
func (u *Manager) SoftDelete(id int) error {
|
||||
// Disallow if user is system user.
|
||||
systemUser, err := u.GetSystemUser()
|
||||
@@ -239,7 +239,7 @@ func (u *Manager) SoftDelete(id int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetEmail retrieves the email of a user by ID.
|
||||
// GetEmail retrieves the email of an user by ID.
|
||||
func (u *Manager) GetEmail(id int) (string, error) {
|
||||
var email string
|
||||
if err := u.q.GetEmail.Get(&email, id); err != nil {
|
||||
@@ -252,7 +252,7 @@ func (u *Manager) GetEmail(id int) (string, error) {
|
||||
return email, nil
|
||||
}
|
||||
|
||||
// SetResetPasswordToken sets a reset password token for a user and returns the token.
|
||||
// SetResetPasswordToken sets a reset password token for an user and returns the token.
|
||||
func (u *Manager) SetResetPasswordToken(id int) (string, error) {
|
||||
token, err := stringutil.RandomAlphanumeric(32)
|
||||
if err != nil {
|
||||
@@ -266,7 +266,7 @@ func (u *Manager) SetResetPasswordToken(id int) (string, error) {
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// ResetPassword sets a new password for a user.
|
||||
// ResetPassword sets a new password for an user.
|
||||
func (u *Manager) ResetPassword(token, password string) error {
|
||||
if !u.isStrongPassword(password) {
|
||||
return envelope.NewError(envelope.InputError, "Password is not strong enough, "+SystemUserPasswordHint, nil)
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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