diff --git a/cmd/handlers.go b/cmd/handlers.go index bc1372d..b4e6725 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -98,7 +98,6 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { 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.PUT("/api/v1/users/me/reassign-replies/toggle", auth(handleToggleReassignReplies)) 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/users.go b/cmd/users.go index eb57c09..a797c75 100644 --- a/cmd/users.go +++ b/cmd/users.go @@ -76,19 +76,6 @@ func handleUpdateUserAvailability(r *fastglue.Request) error { return r.SendEnvelope(true) } -// handleToggleReassignReplies toggles the reassign replies setting for the current user. -func handleToggleReassignReplies(r *fastglue.Request) error { - var ( - app = r.Context.(*App) - auser = r.RequestCtx.UserValue("user").(amodels.User) - enabled = r.RequestCtx.PostArgs().GetBool("enabled") - ) - if err := app.user.ToggleReassignReplies(auser.ID, enabled); err != nil { - return sendErrorEnvelope(r, err) - } - return r.SendEnvelope(true) -} - // handleGetCurrentUserTeams returns the teams of a user. func handleGetCurrentUserTeams(r *fastglue.Request) error { var ( diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 5f8bb9f..93e0dd6 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -276,7 +276,6 @@ const deleteView = (id) => http.delete(`/api/v1/views/me/${id}`) const getAiPrompts = () => http.get('/api/v1/ai/prompts') const aiCompletion = (data) => http.post('/api/v1/ai/completion', data) const updateAIProvider = (data) => http.put('/api/v1/ai/provider', data) -const toggleReassignReplies = (data) => http.put('/api/v1/users/me/reassign-replies/toggle', data) export default { login, @@ -391,5 +390,4 @@ export default { searchMessages, searchContacts, removeAssignee, - toggleReassignReplies, } diff --git a/frontend/src/components/sidebar/SidebarNavUser.vue b/frontend/src/components/sidebar/SidebarNavUser.vue index f478997..8cce1ef 100644 --- a/frontend/src/components/sidebar/SidebarNavUser.vue +++ b/frontend/src/components/sidebar/SidebarNavUser.vue @@ -16,7 +16,8 @@ 'bg-green-500': userStore.user.availability_status === 'online', 'bg-amber-500': userStore.user.availability_status === 'away' || - userStore.user.availability_status === 'away_manual', + userStore.user.availability_status === 'away_manual' || + userStore.user.availability_status === 'away_and_reassigning', 'bg-gray-400': userStore.user.availability_status === 'offline' }" > @@ -47,26 +48,34 @@
- + +
+ {{ t('navigation.away') }} + +
+ +
+ {{ t('navigation.reassign_replies') }} + +
diff --git a/frontend/src/features/admin/users/UserForm.vue b/frontend/src/features/admin/users/UserForm.vue index 7fe6c80..fcc32a5 100644 --- a/frontend/src/features/admin/users/UserForm.vue +++ b/frontend/src/features/admin/users/UserForm.vue @@ -1,8 +1,7 @@ @@ -182,6 +193,14 @@ import { Badge } from '@/components/ui/badge' import { Clock, LogIn } from 'lucide-vue-next' import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select' import { SelectTag } from '@/components/ui/select' import { Input } from '@/components/ui/input' import { useI18n } from 'vue-i18n' @@ -228,9 +247,11 @@ onMounted(async () => { const availabilityStatus = computed(() => { const status = form.values.availability_status - if (status === 'online') return { text: 'Online', color: 'bg-green-500' } - if (status === 'away' || status === 'away_manual') return { text: 'Away', color: 'bg-yellow-500' } - return { text: 'Offline', color: 'bg-gray-400' } + if (status === 'active_group') return { text: t('globals.terms.active'), color: 'bg-green-500' } + if (status === 'away_manual') return { text: t('globals.terms.away'), color: 'bg-yellow-500' } + if (status === 'away_and_reassigning') + return { text: t('form.field.awayReassigning'), color: 'bg-orange-500' } + return { text: t('globals.terms.offline'), color: 'bg-gray-400' } }) const teamOptions = computed(() => @@ -245,6 +266,9 @@ const form = useForm({ }) const onSubmit = form.handleSubmit((values) => { + if (values.availability_status === 'active_group') { + values.availability_status = 'offline' + } values.teams = values.teams.map((team) => ({ name: team })) props.submitForm(values) }) @@ -260,8 +284,15 @@ watch( () => props.initialValues, (newValues) => { // Hack. - if (Object.keys(newValues).length) { + if (Object.keys(newValues).length > 0) { setTimeout(() => { + if ( + newValues.availability_status === 'away' || + newValues.availability_status === 'offline' || + newValues.availability_status === 'online' + ) { + newValues.availability_status = 'active_group' + } form.setValues(newValues) form.setFieldValue( 'teams', diff --git a/frontend/src/features/admin/users/formSchema.js b/frontend/src/features/admin/users/formSchema.js index 6e8cec1..9f64af2 100644 --- a/frontend/src/features/admin/users/formSchema.js +++ b/frontend/src/features/admin/users/formSchema.js @@ -45,5 +45,6 @@ export const createFormSchema = (t) => z.object({ }) }) .optional(), - enabled: z.boolean().optional().default(true) + enabled: z.boolean().optional().default(true), + availability_status: z.string().optional().default('offline'), }) diff --git a/frontend/src/stores/user.js b/frontend/src/stores/user.js index 212df5e..ff9a035 100644 --- a/frontend/src/stores/user.js +++ b/frontend/src/stores/user.js @@ -18,7 +18,6 @@ export const useUserStore = defineStore('user', () => { teams: [], permissions: [], availability_status: 'offline', - reassign_replies: false }) const emitter = useEmitter() @@ -106,22 +105,6 @@ export const useUserStore = defineStore('user', () => { } } - const toggleAssignReplies = async (enabled) => { - const prev = user.value.reassign_replies - user.value.reassign_replies = enabled - try { - await api.toggleReassignReplies({ - enabled: enabled - }) - } catch (error) { - user.value.reassign_replies = prev - emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { - variant: 'destructive', - description: handleHTTPError(error).message - }) - } - } - return { user, userID, @@ -140,7 +123,6 @@ export const useUserStore = defineStore('user', () => { clearAvatar, setAvatar, updateUserAvailability, - toggleAssignReplies, can } }) \ No newline at end of file diff --git a/i18n/en.json b/i18n/en.json index e4a17da..3656c4e 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -73,6 +73,7 @@ "globals.terms.awaitingResponse": "Awaiting Response", "globals.terms.unassigned": "Unassigned", "globals.terms.pending": "Pending", + "globals.terms.active": "Active", "globals.messages.badRequest": "Bad request", "globals.messages.adjustFilters": "Try adjusting filters", "globals.messages.errorUpdating": "Error updating {name}", @@ -238,6 +239,8 @@ "navigation.delete": "Delete", "navigation.reassign_replies": "Reassign replies", "form.field.name": "Name", + "form.field.awayReassigning": "Away and reassigning", + "form.field.select": "Select {name}", "form.field.availabilityStatus": "Availability Status", "form.field.lastActive": "Last active", "form.field.lastLogin": "Last login", @@ -259,7 +262,6 @@ "form.field.default": "Default", "form.field.channel": "Channel", "form.field.configure": "Configure", - "form.field.select": "Select", "form.field.date": "Date", "form.field.description": "Description", "form.field.email": "Email", diff --git a/i18n/mr.json b/i18n/mr.json index 8e9fb3f..00a6a40 100644 --- a/i18n/mr.json +++ b/i18n/mr.json @@ -73,6 +73,7 @@ "globals.terms.awaitingResponse": "प्रतिसाचाची वाट पाहत आहे", "globals.terms.unassigned": "नियुक्त नाही", "globals.terms.pending": "प्रलंबित", + "globals.terms.active": "सक्रिय", "globals.messages.badRequest": "चुकीची विनंती", "globals.messages.adjustFilters": "फिल्टर्स समायोजित करा", "globals.messages.errorUpdating": "{name} अद्ययावत करताना त्रुटी", @@ -238,6 +239,8 @@ "navigation.delete": "हटवा", "navigation.reassign_replies": "प्रतिसाद पुन्हा नियुक्त करा", "form.field.name": "नाव", + "form.field.awayReassigning": "दूर आणि पुन्हा नियुक्त करत आहे", + "form.field.select": "{name} निवडा", "form.field.availabilityStatus": "उपलब्धता स्थिती", "form.field.lastActive": "शेवटचे सक्रिय", "form.field.lastLogin": "शेवटचे लॉगिन", @@ -259,7 +262,6 @@ "form.field.default": "डिफॉल्ट", "form.field.channel": "चॅनेल", "form.field.configure": "कॉन्फिगर करा", - "form.field.select": "निवडा", "form.field.date": "तारीख", "form.field.description": "वर्णन", "form.field.email": "ईमेल", diff --git a/internal/autoassigner/autoassigner.go b/internal/autoassigner/autoassigner.go index 9bd8a0b..31ce0c2 100644 --- a/internal/autoassigner/autoassigner.go +++ b/internal/autoassigner/autoassigner.go @@ -156,9 +156,9 @@ func (e *Engine) populateTeamBalancer() error { balancer := e.roundRobinBalancer[team.ID] existingUsers := make(map[string]struct{}) for _, user := range users { - // Skip user if availability status is `away_manual` - if user.AvailabilityStatus == umodels.AwayManual { - e.lo.Debug("skipping user with away_manual status", "user_id", user.ID) + // Skip user if availability status is `away_manual` or `away_and_reassigning` + if user.AvailabilityStatus == umodels.AwayManual || user.AvailabilityStatus == umodels.AwayAndReassigning { + e.lo.Debug("user is away, skipping autoasssignment ", "team_id", team.ID, "user_id", user.ID, "availability_status", user.AvailabilityStatus) continue } diff --git a/internal/conversation/queries.sql b/internal/conversation/queries.sql index ea3f84e..a15bd5a 100644 --- a/internal/conversation/queries.sql +++ b/internal/conversation/queries.sql @@ -534,7 +534,7 @@ SET WHEN EXISTS ( SELECT 1 FROM users WHERE users.id = conversations.assigned_user_id - AND users.reassign_replies = TRUE + AND users.availability_status = 'away_and_reassigning' ) THEN NULL ELSE assigned_user_id END diff --git a/internal/migrations/v0.6.0.go b/internal/migrations/v0.6.0.go index 8a259b2..821f338 100644 --- a/internal/migrations/v0.6.0.go +++ b/internal/migrations/v0.6.0.go @@ -9,14 +9,25 @@ import ( // V0_6_0 updates the database schema to v0.6.0. func V0_6_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error { _, err := db.Exec(` - ALTER TABLE users ADD COLUMN IF NOT EXISTS reassign_replies BOOL DEFAULT FALSE NOT NULL; + ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ NULL; `) if err != nil { return err } _, err = db.Exec(` - ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ NULL; + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_enum e + JOIN pg_type t ON t.oid = e.enumtypid + WHERE t.typname = 'user_availability_status' + AND e.enumlabel = 'away_and_reassigning' + ) THEN + ALTER TYPE user_availability_status ADD VALUE 'away_and_reassigning'; + END IF; + END + $$; `) if err != nil { return err diff --git a/internal/user/models/models.go b/internal/user/models/models.go index 4a22b1e..3cdee8b 100644 --- a/internal/user/models/models.go +++ b/internal/user/models/models.go @@ -16,10 +16,11 @@ const ( UserTypeContact = "contact" // User availability statuses - Online = "online" - Offline = "offline" - Away = "away" - AwayManual = "away_manual" + Online = "online" + Offline = "offline" + Away = "away" + AwayManual = "away_manual" + AwayAndReassigning = "away_and_reassigning" ) type User struct { @@ -37,7 +38,6 @@ type User struct { Password string `db:"password" json:"-"` LastActiveAt null.Time `db:"last_active_at" json:"last_active_at"` LastLoginAt null.Time `db:"last_login_at" json:"last_login_at"` - ReassignReplies bool `db:"reassign_replies" json:"reassign_replies"` Roles pq.StringArray `db:"roles" json:"roles"` Permissions pq.StringArray `db:"permissions" json:"permissions"` Meta pq.StringArray `db:"meta" json:"meta"` diff --git a/internal/user/queries.sql b/internal/user/queries.sql index 91c8841..0a9af44 100644 --- a/internal/user/queries.sql +++ b/internal/user/queries.sql @@ -78,6 +78,7 @@ SET first_name = COALESCE($2, first_name), avatar_url = COALESCE($6, avatar_url), password = COALESCE($7, password), enabled = COALESCE($8, enabled), + availability_status = COALESCE($9, availability_status), updated_at = now() WHERE id = $1 AND type = 'agent'; diff --git a/internal/user/user.go b/internal/user/user.go index 30c866f..eebac08 100644 --- a/internal/user/user.go +++ b/internal/user/user.go @@ -224,7 +224,7 @@ func (u *Manager) Update(id int, user models.User) error { u.lo.Debug("setting new password for user", "user_id", id) } - if _, err := u.q.UpdateUser.Exec(id, user.FirstName, user.LastName, user.Email, pq.Array(user.Roles), user.AvatarURL, hashedPassword, user.Enabled); err != nil { + if _, err := u.q.UpdateUser.Exec(id, user.FirstName, user.LastName, user.Email, pq.Array(user.Roles), user.AvatarURL, hashedPassword, user.Enabled, user.AvailabilityStatus); err != nil { u.lo.Error("error updating user", "error", err) return envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}"), nil) } @@ -303,15 +303,6 @@ func (u *Manager) UpdateAvailability(id int, status string) error { return nil } -// ToggleReassignReplies toggles the reassign replies status of an user. -func (u *Manager) ToggleReassignReplies(id int, reassign bool) error { - if _, err := u.q.SetReassignReplies.Exec(id, reassign); err != nil { - u.lo.Error("error updating user reassign replies", "error", err) - return envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}"), 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 { diff --git a/schema.sql b/schema.sql index a565c65..53c5219 100644 --- a/schema.sql +++ b/schema.sql @@ -13,7 +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'); +DROP TYPE IF EXISTS "user_availability_status" CASCADE; CREATE TYPE "user_availability_status" AS ENUM ('online', 'away', 'away_manual', 'offline', 'away_and_reassigning'); DROP TYPE IF EXISTS "applied_sla_status" CASCADE; CREATE TYPE "applied_sla_status" AS ENUM ('pending', 'breached', 'met', 'partially_met'); DROP TYPE IF EXISTS "sla_metric" CASCADE; CREATE TYPE "sla_metric" AS ENUM ('first_response', 'resolution'); DROP TYPE IF EXISTS "sla_notification_type" CASCADE; CREATE TYPE "sla_notification_type" AS ENUM ('warning', 'breach'); @@ -126,7 +126,6 @@ CREATE TABLE users ( availability_status user_availability_status DEFAULT 'offline' NOT NULL, last_active_at TIMESTAMPTZ NULL, last_login_at TIMESTAMPTZ NULL, - reassign_replies BOOL DEFAULT FALSE NOT 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),