From 67e123048596cede60d362d17a5f7baa719d9b96 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Wed, 26 Feb 2025 04:34:30 +0530 Subject: [PATCH] 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 --- cmd/handlers.go | 1 + cmd/init.go | 5 + cmd/login.go | 13 +- cmd/main.go | 4 +- cmd/middlewares.go | 4 +- cmd/upgrade.go | 5 +- cmd/users.go | 21 ++- frontend/package.json | 2 +- frontend/src/App.vue | 7 +- frontend/src/api/index.js | 2 + .../src/components/sidebar/SidebarNavUser.vue | 153 ++++++++++-------- frontend/src/composables/useIdleDetection.js | 43 +++++ .../src/features/reports/DashboardCard.vue | 19 ++- frontend/src/stores/user.js | 27 +++- frontend/src/utils/debounce.js | 7 + frontend/src/views/inbox/InboxView.vue | 2 +- frontend/src/views/login/UserLoginView.vue | 7 +- frontend/src/views/reports/DashboardView.vue | 73 +++++---- internal/conversation/queries.sql | 5 +- internal/migrations/v0.3.0.go | 22 +++ internal/user/models/models.go | 52 +++--- internal/user/queries.sql | 67 ++++---- internal/user/user.go | 84 +++++++--- internal/ws/client.go | 2 + internal/ws/ws.go | 9 +- schema.sql | 3 + 26 files changed, 435 insertions(+), 204 deletions(-) create mode 100644 frontend/src/composables/useIdleDetection.js create mode 100644 frontend/src/utils/debounce.js create mode 100644 internal/migrations/v0.3.0.go diff --git a/cmd/handlers.go b/cmd/handlers.go index a3bc956..dbb31b5 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -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")) diff --git a/cmd/init.go b/cmd/init.go index 8537a2a..d3eddcd 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -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 ( diff --git a/cmd/login.go b/cmd/login.go index 05903c1..8bf1eff 100644 --- a/cmd/login.go +++ b/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, diff --git a/cmd/main.go b/cmd/main.go index 733e1eb..ad0ac97 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -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, diff --git a/cmd/middlewares.go b/cmd/middlewares.go index 378e287..6ddd0c7 100644 --- a/cmd/middlewares.go +++ b/cmd/middlewares.go @@ -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) diff --git a/cmd/upgrade.go b/cmd/upgrade.go index a3cc575..ae08f26 100644 --- a/cmd/upgrade.go +++ b/cmd/upgrade.go @@ -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. diff --git a/cmd/users.go b/cmd/users.go index df4d8cd..e91bb94 100644 --- a/cmd/users.go +++ b/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.") diff --git a/frontend/package.json b/frontend/package.json index 552d501..5b6c114 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "libredesk", - "version": "0.0.0", + "version": "0.3.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 57ad4a7..f0c0a5d 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -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(), diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 7883b89..013013c 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -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, diff --git a/frontend/src/components/sidebar/SidebarNavUser.vue b/frontend/src/components/sidebar/SidebarNavUser.vue index 1014016..f36b521 100644 --- a/frontend/src/components/sidebar/SidebarNavUser.vue +++ b/frontend/src/components/sidebar/SidebarNavUser.vue @@ -1,82 +1,93 @@ \ No newline at end of file + diff --git a/frontend/src/composables/useIdleDetection.js b/frontend/src/composables/useIdleDetection.js new file mode 100644 index 0000000..0547c53 --- /dev/null +++ b/frontend/src/composables/useIdleDetection.js @@ -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) + }) +} \ No newline at end of file diff --git a/frontend/src/features/reports/DashboardCard.vue b/frontend/src/features/reports/DashboardCard.vue index d8d4de3..2ae3c30 100644 --- a/frontend/src/features/reports/DashboardCard.vue +++ b/frontend/src/features/reports/DashboardCard.vue @@ -1,25 +1,36 @@ diff --git a/frontend/src/stores/user.js b/frontend/src/stores/user.js index e03eacd..652f85d 100644 --- a/frontend/src/stores/user.js +++ b/frontend/src/stores/user.js @@ -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 } -}) +}) \ No newline at end of file diff --git a/frontend/src/utils/debounce.js b/frontend/src/utils/debounce.js new file mode 100644 index 0000000..7705b03 --- /dev/null +++ b/frontend/src/utils/debounce.js @@ -0,0 +1,7 @@ +export function debounce (fn, delay) { + let timeout + return function (...args) { + clearTimeout(timeout) + timeout = setTimeout(() => fn(...args), delay) + } +} diff --git a/frontend/src/views/inbox/InboxView.vue b/frontend/src/views/inbox/InboxView.vue index b7b844b..ef4cc40 100644 --- a/frontend/src/views/inbox/InboxView.vue +++ b/frontend/src/views/inbox/InboxView.vue @@ -1,5 +1,5 @@ diff --git a/frontend/src/views/login/UserLoginView.vue b/frontend/src/views/login/UserLoginView.vue index 76f5df8..c4d5029 100644 --- a/frontend/src/views/login/UserLoginView.vue +++ b/frontend/src/views/login/UserLoginView.vue @@ -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) => { diff --git a/frontend/src/views/reports/DashboardView.vue b/frontend/src/views/reports/DashboardView.vue index 79309f1..0b2efe6 100644 --- a/frontend/src/views/reports/DashboardView.vue +++ b/frontend/src/views/reports/DashboardView.vue @@ -1,27 +1,29 @@