mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-10-23 05:11:57 +00:00
feat: agent availability status
New columns in users table to store user availability status. Websocket pings sets the last active at timestamp, once user stops sending pings (on disconnect) after 5 minutes the user availalbility status changes to offline. Detects auto away by checking for mouse, keyboard events and sets user status to away. User can also set their status to away manually from the sidebar. Migrations for v0.3.0 Minor visual fixes. Bump version in package.json
This commit is contained in:
@@ -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 (
|
||||
|
13
cmd/login.go
13
cmd/login.go
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
umodels "github.com/abhinavxd/libredesk/internal/user/models"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
@@ -11,14 +12,20 @@ import (
|
||||
func handleLogin(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
p = r.RequestCtx.PostArgs()
|
||||
email = string(p.Peek("email"))
|
||||
password = p.Peek("password")
|
||||
email = string(r.RequestCtx.PostArgs().Peek("email"))
|
||||
password = r.RequestCtx.PostArgs().Peek("password")
|
||||
)
|
||||
user, err := app.user.VerifyPassword(email, password)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Set user availability status to online.
|
||||
if err := app.user.UpdateAvailability(user.ID, umodels.Online); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
user.AvailabilityStatus = umodels.Online
|
||||
|
||||
if err := app.auth.SaveSession(amodels.User{
|
||||
ID: user.ID,
|
||||
Email: user.Email.String,
|
||||
|
@@ -36,7 +36,6 @@ import (
|
||||
"github.com/abhinavxd/libredesk/internal/team"
|
||||
"github.com/abhinavxd/libredesk/internal/template"
|
||||
"github.com/abhinavxd/libredesk/internal/user"
|
||||
"github.com/abhinavxd/libredesk/internal/ws"
|
||||
"github.com/knadh/go-i18n"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/knadh/stuffbin"
|
||||
@@ -162,7 +161,6 @@ func main() {
|
||||
messageOutgoingScanInterval = ko.MustDuration("message.message_outoing_scan_interval")
|
||||
slaEvaluationInterval = ko.MustDuration("sla.evaluation_interval")
|
||||
lo = initLogger(appName)
|
||||
wsHub = ws.NewHub()
|
||||
rdb = initRedis()
|
||||
constants = initConstants()
|
||||
i18n = initI18n(fs)
|
||||
@@ -177,6 +175,7 @@ func main() {
|
||||
team = initTeam(db)
|
||||
businessHours = initBusinessHours(db)
|
||||
user = initUser(i18n, db)
|
||||
wsHub = initWS(user)
|
||||
notifier = initNotifier(user)
|
||||
automation = initAutomationEngine(db)
|
||||
sla = initSLA(db, team, settings, businessHours)
|
||||
@@ -193,6 +192,7 @@ func main() {
|
||||
go notifier.Run(ctx)
|
||||
go sla.Run(ctx, slaEvaluationInterval)
|
||||
go media.DeleteUnlinkedMedia(ctx)
|
||||
go user.MonitorAgentAvailability(ctx)
|
||||
|
||||
var app = &App{
|
||||
lo: lo,
|
||||
|
@@ -43,9 +43,7 @@ func tryAuth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
|
||||
// auth makes sure the user is logged in.
|
||||
func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
|
||||
return func(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
)
|
||||
var app = r.Context.(*App)
|
||||
|
||||
// Validate session and fetch user.
|
||||
userSession, err := app.auth.ValidateSession(r)
|
||||
|
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/dbutil"
|
||||
"github.com/abhinavxd/libredesk/internal/migrations"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/knadh/stuffbin"
|
||||
@@ -28,7 +29,9 @@ type migFunc struct {
|
||||
// migList is the list of available migList ordered by the semver.
|
||||
// Each migration is a Go file in internal/migrations named after the semver.
|
||||
// The functions are named as: v0.7.0 => migrations.V0_7_0() and are idempotent.
|
||||
var migList = []migFunc{}
|
||||
var migList = []migFunc{
|
||||
{"v0.3.0", migrations.V0_3_0},
|
||||
}
|
||||
|
||||
// upgrade upgrades the database to the current version by running SQL migration files
|
||||
// for all version from the last known version to the current one.
|
||||
|
21
cmd/users.go
21
cmd/users.go
@@ -22,7 +22,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
maxAvatarSizeMB = 5
|
||||
maxAvatarSizeMB = 20
|
||||
)
|
||||
|
||||
// handleGetUsers returns all users.
|
||||
@@ -39,9 +39,7 @@ func handleGetUsers(r *fastglue.Request) error {
|
||||
|
||||
// handleGetUsersCompact returns all users in a compact format.
|
||||
func handleGetUsersCompact(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
)
|
||||
var app = r.Context.(*App)
|
||||
agents, err := app.user.GetAllCompact()
|
||||
if err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
|
||||
@@ -66,6 +64,19 @@ func handleGetUser(r *fastglue.Request) error {
|
||||
return r.SendEnvelope(user)
|
||||
}
|
||||
|
||||
// handleUpdateUserAvailability updates the current user availability.
|
||||
func handleUpdateUserAvailability(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
status = string(r.RequestCtx.PostArgs().Peek("status"))
|
||||
)
|
||||
if err := app.user.UpdateAvailability(auser.ID, status); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope("User availability updated successfully.")
|
||||
}
|
||||
|
||||
// handleGetCurrentUserTeams returns the teams of a user.
|
||||
func handleGetCurrentUserTeams(r *fastglue.Request) error {
|
||||
var (
|
||||
@@ -228,7 +239,7 @@ func handleCreateUser(r *fastglue.Request) error {
|
||||
Provider: notifier.ProviderEmail,
|
||||
}); err != nil {
|
||||
app.lo.Error("error sending notification message", "error", err)
|
||||
return r.SendEnvelope("User created successfully, but error sending welcome email.")
|
||||
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "User created successfully, but could not send welcome email.", nil))
|
||||
}
|
||||
}
|
||||
return r.SendEnvelope("User created successfully.")
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "libredesk",
|
||||
"version": "0.0.0",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
@@ -81,6 +81,7 @@ import { useTeamStore } from '@/stores/team'
|
||||
import { useSlaStore } from '@/stores/sla'
|
||||
import { useMacroStore } from '@/stores/macro'
|
||||
import { useTagStore } from '@/stores/tag'
|
||||
import { useIdleDetection } from '@/composables/useIdleDetection'
|
||||
import PageHeader from './components/layout/PageHeader.vue'
|
||||
import ViewForm from '@/features/view/ViewForm.vue'
|
||||
import AppUpdate from '@/components/update/AppUpdate.vue'
|
||||
@@ -118,6 +119,8 @@ const view = ref({})
|
||||
const openCreateViewForm = ref(false)
|
||||
|
||||
initWS()
|
||||
useIdleDetection()
|
||||
|
||||
onMounted(() => {
|
||||
initToaster()
|
||||
listenViewRefresh()
|
||||
@@ -126,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>
|
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,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>
|
||||
|
@@ -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, {
|
||||
|
@@ -234,7 +234,10 @@ SELECT json_build_object(
|
||||
'open', COUNT(*),
|
||||
'awaiting_response', COUNT(CASE WHEN c.waiting_since IS NOT NULL THEN 1 END),
|
||||
'unassigned', COUNT(CASE WHEN c.assigned_user_id IS NULL THEN 1 END),
|
||||
'pending', COUNT(CASE WHEN c.first_reply_at IS NOT NULL THEN 1 END)
|
||||
'pending', COUNT(CASE WHEN c.first_reply_at IS NOT NULL THEN 1 END),
|
||||
'agents_online', (SELECT COUNT(*) FROM users WHERE availability_status = 'online' AND type = 'agent' AND deleted_at is null),
|
||||
'agents_away', (SELECT COUNT(*) FROM users WHERE availability_status in ('away', 'away_manual') AND type = 'agent' AND deleted_at is null),
|
||||
'agents_offline', (SELECT COUNT(*) FROM users WHERE availability_status = 'offline' AND type = 'agent' AND deleted_at is null)
|
||||
)
|
||||
FROM conversations c
|
||||
INNER JOIN conversation_statuses s ON c.status_id = s.id
|
||||
|
22
internal/migrations/v0.3.0.go
Normal file
22
internal/migrations/v0.3.0.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/knadh/stuffbin"
|
||||
)
|
||||
|
||||
// V0_3_0 updates the database schema to v0.3.0.
|
||||
func V0_3_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
|
||||
_, err := db.Exec(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_availability_status') THEN
|
||||
CREATE TYPE user_availability_status AS ENUM ('online', 'away', 'away_manual', 'offline');
|
||||
END IF;
|
||||
END$$;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS availability_status user_availability_status DEFAULT 'offline' NOT NULL;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_active_at TIMESTAMPTZ NULL;
|
||||
`)
|
||||
return err
|
||||
}
|
@@ -8,29 +8,37 @@ import (
|
||||
"github.com/volatiletech/null/v9"
|
||||
)
|
||||
|
||||
var (
|
||||
Online = "online"
|
||||
Offline = "offline"
|
||||
Away = "away"
|
||||
AwayManual = "away_manual"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID int `db:"id" json:"id,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
FirstName string `db:"first_name" json:"first_name"`
|
||||
LastName string `db:"last_name" json:"last_name"`
|
||||
Email null.String `db:"email" json:"email,omitempty"`
|
||||
Type string `db:"type" json:"type"`
|
||||
PhoneNumber null.String `db:"phone_number" json:"phone_number,omitempty"`
|
||||
AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
|
||||
Enabled bool `db:"enabled" json:"enabled"`
|
||||
Password string `db:"password" json:"-"`
|
||||
Roles pq.StringArray `db:"roles" json:"roles,omitempty"`
|
||||
Permissions pq.StringArray `db:"permissions" json:"permissions,omitempty"`
|
||||
Meta pq.StringArray `db:"meta" json:"meta,omitempty"`
|
||||
CustomAttributes pq.StringArray `db:"custom_attributes" json:"custom_attributes,omitempty"`
|
||||
Teams tmodels.Teams `db:"teams" json:"teams,omitempty"`
|
||||
ContactChannelID int `db:"contact_channel_id" json:"contact_channel_id,omitempty"`
|
||||
NewPassword string `db:"-" json:"new_password,omitempty"`
|
||||
SendWelcomeEmail bool `db:"-" json:"send_welcome_email,omitempty"`
|
||||
InboxID int `json:"-"`
|
||||
SourceChannel null.String `json:"-"`
|
||||
SourceChannelID null.String `json:"-"`
|
||||
ID int `db:"id" json:"id,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
FirstName string `db:"first_name" json:"first_name"`
|
||||
LastName string `db:"last_name" json:"last_name"`
|
||||
Email null.String `db:"email" json:"email,omitempty"`
|
||||
Type string `db:"type" json:"type"`
|
||||
AvailabilityStatus string `db:"availability_status" json:"availability_status"`
|
||||
PhoneNumber null.String `db:"phone_number" json:"phone_number,omitempty"`
|
||||
AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
|
||||
Enabled bool `db:"enabled" json:"enabled"`
|
||||
Password string `db:"password" json:"-"`
|
||||
Roles pq.StringArray `db:"roles" json:"roles,omitempty"`
|
||||
Permissions pq.StringArray `db:"permissions" json:"permissions,omitempty"`
|
||||
Meta pq.StringArray `db:"meta" json:"meta,omitempty"`
|
||||
CustomAttributes pq.StringArray `db:"custom_attributes" json:"custom_attributes,omitempty"`
|
||||
Teams tmodels.Teams `db:"teams" json:"teams,omitempty"`
|
||||
ContactChannelID int `db:"contact_channel_id" json:"contact_channel_id,omitempty"`
|
||||
NewPassword string `db:"-" json:"new_password,omitempty"`
|
||||
SendWelcomeEmail bool `db:"-" json:"send_welcome_email,omitempty"`
|
||||
InboxID int `json:"-"`
|
||||
SourceChannel null.String `json:"-"`
|
||||
SourceChannelID null.String `json:"-"`
|
||||
}
|
||||
|
||||
func (u *User) FullName() string {
|
||||
|
@@ -20,41 +20,32 @@ SELECT email
|
||||
FROM users
|
||||
WHERE id = $1 AND deleted_at IS NULL AND type = 'agent';
|
||||
|
||||
-- name: get-user-by-email
|
||||
SELECT u.id, u.email, u.password, u.avatar_url, u.first_name, u.last_name, u.enabled,
|
||||
array_agg(DISTINCT r.name) as roles,
|
||||
array_agg(DISTINCT p) as permissions
|
||||
FROM users u
|
||||
JOIN user_roles ur ON ur.user_id = u.id
|
||||
JOIN roles r ON r.id = ur.role_id,
|
||||
unnest(r.permissions) p
|
||||
WHERE u.email = $1 AND u.deleted_at IS NULL AND u.type = 'agent'
|
||||
GROUP BY u.id;
|
||||
|
||||
-- name: get-user
|
||||
SELECT
|
||||
u.id,
|
||||
u.created_at,
|
||||
u.updated_at,
|
||||
u.enabled,
|
||||
u.email,
|
||||
u.avatar_url,
|
||||
u.first_name,
|
||||
u.last_name,
|
||||
array_agg(DISTINCT r.name) as roles,
|
||||
COALESCE(
|
||||
(SELECT json_agg(json_build_object('id', t.id, 'name', t.name, 'emoji', t.emoji))
|
||||
FROM team_members tm
|
||||
JOIN teams t ON tm.team_id = t.id
|
||||
WHERE tm.user_id = u.id),
|
||||
'[]'
|
||||
) AS teams,
|
||||
array_agg(DISTINCT p) as permissions
|
||||
u.id,
|
||||
u.email,
|
||||
u.password,
|
||||
u.created_at,
|
||||
u.updated_at,
|
||||
u.enabled,
|
||||
u.avatar_url,
|
||||
u.first_name,
|
||||
u.last_name,
|
||||
u.availability_status,
|
||||
array_agg(DISTINCT r.name) as roles,
|
||||
COALESCE(
|
||||
(SELECT json_agg(json_build_object('id', t.id, 'name', t.name, 'emoji', t.emoji))
|
||||
FROM team_members tm
|
||||
JOIN teams t ON tm.team_id = t.id
|
||||
WHERE tm.user_id = u.id),
|
||||
'[]'
|
||||
) AS teams,
|
||||
array_agg(DISTINCT p) as permissions
|
||||
FROM users u
|
||||
LEFT JOIN user_roles ur ON ur.user_id = u.id
|
||||
LEFT JOIN roles r ON r.id = ur.role_id,
|
||||
unnest(r.permissions) p
|
||||
WHERE u.id = $1 AND u.deleted_at IS NULL AND u.type = 'agent'
|
||||
unnest(r.permissions) p
|
||||
WHERE (u.id = $1 OR u.email = $2) AND u.deleted_at IS NULL AND u.type = 'agent'
|
||||
GROUP BY u.id;
|
||||
|
||||
-- name: set-user-password
|
||||
@@ -92,6 +83,22 @@ UPDATE users
|
||||
SET avatar_url = $2, updated_at = now()
|
||||
WHERE id = $1 AND type = 'agent';
|
||||
|
||||
-- name: update-availability
|
||||
UPDATE users
|
||||
SET availability_status = $2
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: update-last-active-at
|
||||
UPDATE users
|
||||
SET last_active_at = now(),
|
||||
availability_status = CASE WHEN availability_status = 'offline' THEN 'online' ELSE availability_status END
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: update-inactive-offline
|
||||
UPDATE users
|
||||
SET availability_status = 'offline'
|
||||
WHERE last_active_at < now() - interval '5 minutes' and availability_status != 'offline';
|
||||
|
||||
-- name: get-permissions
|
||||
SELECT DISTINCT unnest(r.permissions)
|
||||
FROM users u
|
||||
|
@@ -10,6 +10,7 @@ import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"log"
|
||||
|
||||
@@ -61,13 +62,15 @@ type Opts struct {
|
||||
// queries contains prepared SQL queries.
|
||||
type queries struct {
|
||||
GetUsers *sqlx.Stmt `query:"get-users"`
|
||||
GetUserCompact *sqlx.Stmt `query:"get-users-compact"`
|
||||
GetUsersCompact *sqlx.Stmt `query:"get-users-compact"`
|
||||
GetUser *sqlx.Stmt `query:"get-user"`
|
||||
GetEmail *sqlx.Stmt `query:"get-email"`
|
||||
GetPermissions *sqlx.Stmt `query:"get-permissions"`
|
||||
GetUserByEmail *sqlx.Stmt `query:"get-user-by-email"`
|
||||
UpdateUser *sqlx.Stmt `query:"update-user"`
|
||||
UpdateAvatar *sqlx.Stmt `query:"update-avatar"`
|
||||
UpdateAvailability *sqlx.Stmt `query:"update-availability"`
|
||||
UpdateLastActiveAt *sqlx.Stmt `query:"update-last-active-at"`
|
||||
UpdateInactiveOffline *sqlx.Stmt `query:"update-inactive-offline"`
|
||||
SoftDeleteUser *sqlx.Stmt `query:"soft-delete-user"`
|
||||
SetUserPassword *sqlx.Stmt `query:"set-user-password"`
|
||||
SetResetPasswordToken *sqlx.Stmt `query:"set-reset-password-token"`
|
||||
@@ -89,22 +92,19 @@ func New(i18n *i18n.I18n, opts Opts) (*Manager, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// VerifyPassword authenticates a user by email and password.
|
||||
// VerifyPassword authenticates an user by email and password.
|
||||
func (u *Manager) VerifyPassword(email string, password []byte) (models.User, error) {
|
||||
var user models.User
|
||||
|
||||
if err := u.q.GetUserByEmail.Get(&user, email); err != nil {
|
||||
if err := u.q.GetUser.Get(&user, 0, email); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return user, envelope.NewError(envelope.InputError, u.i18n.T("user.invalidEmailPassword"), nil)
|
||||
}
|
||||
u.lo.Error("error fetching user from db", "error", err)
|
||||
return user, envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorFetching", "name", "{globals.entities.user}"), nil)
|
||||
}
|
||||
|
||||
if err := u.verifyPassword(password, user.Password); err != nil {
|
||||
return user, envelope.NewError(envelope.InputError, u.i18n.T("user.invalidEmailPassword"), nil)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ func (u *Manager) GetAll() ([]models.User, error) {
|
||||
// GetAllCompact returns a compact list of users with limited fields.
|
||||
func (u *Manager) GetAllCompact() ([]models.User, error) {
|
||||
var users = make([]models.User, 0)
|
||||
if err := u.q.GetUserCompact.Select(&users); err != nil {
|
||||
if err := u.q.GetUsersCompact.Select(&users); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return users, nil
|
||||
}
|
||||
@@ -154,10 +154,10 @@ func (u *Manager) CreateAgent(user *models.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get retrieves a user by ID.
|
||||
// Get retrieves an user by ID.
|
||||
func (u *Manager) Get(id int) (models.User, error) {
|
||||
var user models.User
|
||||
if err := u.q.GetUser.Get(&user, id); err != nil {
|
||||
if err := u.q.GetUser.Get(&user, id, ""); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
u.lo.Error("user not found", "id", id, "error", err)
|
||||
return user, envelope.NewError(envelope.GeneralError, "User not found", nil)
|
||||
@@ -168,10 +168,10 @@ func (u *Manager) Get(id int) (models.User, error) {
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// GetByEmail retrieves a user by email
|
||||
// GetByEmail retrieves an user by email
|
||||
func (u *Manager) GetByEmail(email string) (models.User, error) {
|
||||
var user models.User
|
||||
if err := u.q.GetUserByEmail.Get(&user, email); err != nil {
|
||||
if err := u.q.GetUser.Get(&user, 0, email); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return user, envelope.NewError(envelope.GeneralError, "User not found", nil)
|
||||
}
|
||||
@@ -195,10 +195,10 @@ func (u *Manager) UpdateAvatar(id int, avatar string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update updates a user.
|
||||
// Update updates an user.
|
||||
func (u *Manager) Update(id int, user models.User) error {
|
||||
var (
|
||||
hashedPassword interface{}
|
||||
hashedPassword any
|
||||
err error
|
||||
)
|
||||
|
||||
@@ -221,7 +221,7 @@ func (u *Manager) Update(id int, user models.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SoftDelete soft deletes a user.
|
||||
// SoftDelete soft deletes an user.
|
||||
func (u *Manager) SoftDelete(id int) error {
|
||||
// Disallow if user is system user.
|
||||
systemUser, err := u.GetSystemUser()
|
||||
@@ -239,7 +239,7 @@ func (u *Manager) SoftDelete(id int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetEmail retrieves the email of a user by ID.
|
||||
// GetEmail retrieves the email of an user by ID.
|
||||
func (u *Manager) GetEmail(id int) (string, error) {
|
||||
var email string
|
||||
if err := u.q.GetEmail.Get(&email, id); err != nil {
|
||||
@@ -252,7 +252,7 @@ func (u *Manager) GetEmail(id int) (string, error) {
|
||||
return email, nil
|
||||
}
|
||||
|
||||
// SetResetPasswordToken sets a reset password token for a user and returns the token.
|
||||
// SetResetPasswordToken sets a reset password token for an user and returns the token.
|
||||
func (u *Manager) SetResetPasswordToken(id int) (string, error) {
|
||||
token, err := stringutil.RandomAlphanumeric(32)
|
||||
if err != nil {
|
||||
@@ -266,7 +266,7 @@ func (u *Manager) SetResetPasswordToken(id int) (string, error) {
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// ResetPassword sets a new password for a user.
|
||||
// ResetPassword sets a new password for an user.
|
||||
func (u *Manager) ResetPassword(token, password string) error {
|
||||
if !u.isStrongPassword(password) {
|
||||
return envelope.NewError(envelope.InputError, "Password is not strong enough, "+SystemUserPasswordHint, nil)
|
||||
@@ -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 {
|
||||
|
@@ -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