-
+
+
+
+
+
+ Last updated: {{ new Date(lastUpdate).toLocaleTimeString() }}
+
+
+
+
+
+
+
+
+
+
+
@@ -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, {
diff --git a/internal/conversation/queries.sql b/internal/conversation/queries.sql
index 42e428d..4557e3c 100644
--- a/internal/conversation/queries.sql
+++ b/internal/conversation/queries.sql
@@ -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
diff --git a/internal/migrations/v0.3.0.go b/internal/migrations/v0.3.0.go
new file mode 100644
index 0000000..6ed0515
--- /dev/null
+++ b/internal/migrations/v0.3.0.go
@@ -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
+}
diff --git a/internal/user/models/models.go b/internal/user/models/models.go
index ef15aee..004381f 100644
--- a/internal/user/models/models.go
+++ b/internal/user/models/models.go
@@ -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 {
diff --git a/internal/user/queries.sql b/internal/user/queries.sql
index 12431f3..8f0a307 100644
--- a/internal/user/queries.sql
+++ b/internal/user/queries.sql
@@ -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
diff --git a/internal/user/user.go b/internal/user/user.go
index 68eb25d..1dbcaff 100644
--- a/internal/user/user.go
+++ b/internal/user/user.go
@@ -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 {
diff --git a/internal/ws/client.go b/internal/ws/client.go
index 484c377..1a140fa 100644
--- a/internal/ws/client.go
+++ b/internal/ws/client.go
@@ -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
}
diff --git a/internal/ws/ws.go b/internal/ws/ws.go
index f8daa1e..8a6b170 100644
--- a/internal/ws/ws.go
+++ b/internal/ws/ws.go
@@ -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,
}
}
diff --git a/schema.sql b/schema.sql
index 7bdbd0d..67e4c3b 100644
--- a/schema.sql
+++ b/schema.sql
@@ -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),