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 @@