feat(aggressive-alert): filtro por inbox configurável por admin

Admin configura em Account Settings → Agents → Editar quais inboxes
disparam o banner agressivo (reopened + inatividade) pra cada agente.

- user.ui_settings.aggressive_alert_inbox_ids: null (todas) | [] (nenhuma) | [1,2,3]
- Filtro aplicado no actionCable.feedInactivityTracker, maybeTriggerAggressiveAlert
  e no hydrate do AggressiveConversationBanner
- Backend aceita ui_settings no agents#update e serializa em _agent.json.jbuilder
- UI no EditAgent com toggle "todas inboxes" + multiselect

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rodribm10 2026-04-24 11:51:00 -03:00
parent 49a21c845b
commit b69fa21e53
8 changed files with 138 additions and 4 deletions

View File

@ -23,7 +23,9 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
end
def update
@agent.update!(agent_params.slice(:name).compact)
user_attrs = agent_params.slice(:name).compact
user_attrs[:ui_settings] = merged_ui_settings if agent_params[:ui_settings].present?
@agent.update!(user_attrs) if user_attrs.any?
@agent.current_account_user.update!(agent_params.slice(*account_user_attributes).compact)
end
@ -72,13 +74,19 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
end
def allowed_agent_params
[:name, :email, :role, :availability, :auto_offline]
[:name, :email, :role, :availability, :auto_offline, { ui_settings: [:aggressive_alert_inbox_ids_mode, { aggressive_alert_inbox_ids: [] }] }]
end
def agent_params
params.require(:agent).permit(allowed_agent_params)
end
def merged_ui_settings
existing = @agent.ui_settings || {}
incoming = agent_params[:ui_settings].to_h.deep_stringify_keys
existing.merge(incoming)
end
def new_agent_params
params.require(:agent).permit(:email, :name, :role, :availability, :auto_offline)
end

View File

@ -17,7 +17,18 @@ export default {
...mapGetters({
currentAccountId: 'getCurrentAccountId',
allConversations: 'getAllConversations',
currentUser: 'getCurrentUser',
}),
allowedInboxIds() {
// null sem filtro (todas); array só essas.
const raw =
this.currentUser &&
this.currentUser.ui_settings &&
this.currentUser.ui_settings.aggressive_alert_inbox_ids;
if (raw == null) return null;
if (!Array.isArray(raw)) return null;
return raw.map(id => Number(id));
},
hasAlerts() {
return this.alerts.length > 0;
},
@ -78,7 +89,14 @@ export default {
// quando o usuário só abriu a aba sem receber mensagem ao vivo.
allConversations: {
handler(conversations) {
inactivityAlertTracker.hydrateFromConversations(conversations);
const allowed = this.allowedInboxIds;
const filtered =
allowed === null
? conversations
: (conversations || []).filter(c =>
allowed.includes(Number(c && c.inbox_id))
);
inactivityAlertTracker.hydrateFromConversations(filtered);
},
immediate: true,
},

View File

@ -133,12 +133,14 @@ class ActionCableConnector extends BaseActionCableConnector {
const isIncoming = messageType === 0 || messageType === 'incoming';
const conversationStatus = conversation && conversation.status;
if (isIncoming && conversationStatus === 'open') {
const inboxId = conversation && conversation.inbox_id;
if (!this.isInboxAllowedForUser(inboxId)) return;
const contactName =
conversation && conversation.meta && conversation.meta.sender
? conversation.meta.sender.name
: '';
const inbox = this.app.$store.getters['inboxes/getInbox']
? this.app.$store.getters['inboxes/getInbox'](conversation.inbox_id)
? this.app.$store.getters['inboxes/getInbox'](inboxId)
: null;
const inboxName = inbox && inbox.name ? inbox.name : '';
inactivityAlertTracker.onClientMessage({
@ -174,6 +176,22 @@ class ActionCableConnector extends BaseActionCableConnector {
return accountEnabled && userEnabled;
};
// Filtra alertas por inbox conforme a preferência do user.
// ui_settings.aggressive_alert_inbox_ids:
// - null/undefined → todas as inboxes (default, legado)
// - [] (vazio) → nenhuma inbox (silenciou tudo)
// - [1, 2, 3] → só essas inboxes
isInboxAllowedForUser = inboxId => {
if (inboxId == null) return true;
const user = this.app.$store.getters.getCurrentUser;
const allowed =
user && user.ui_settings && user.ui_settings.aggressive_alert_inbox_ids;
if (allowed == null) return true;
if (!Array.isArray(allowed)) return true;
// Inbox ids podem vir como number no evento e string no ui_settings.
return allowed.some(id => Number(id) === Number(inboxId));
};
// eslint-disable-next-line class-methods-use-this
onReload = () => window.location.reload();
@ -194,6 +212,7 @@ class ActionCableConnector extends BaseActionCableConnector {
maybeTriggerAggressiveAlert = data => {
if (!data || data.status !== 'open') return;
if (!this.isAggressiveAlertEnabled()) return;
if (!this.isInboxAllowedForUser(data.inbox_id)) return;
const store = this.app.$store;
const contactName =
data.meta && data.meta.sender ? data.meta.sender.name : '';

View File

@ -94,6 +94,13 @@
"ADMIN_SUCCESS_MESSAGE": "An email with reset password instructions has been sent to the agent",
"SUCCESS_MESSAGE": "Agent password reset successfully",
"ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
},
"AGGRESSIVE_ALERT": {
"LABEL": "Aggressive alert — inboxes",
"DESCRIPTION": "Choose which inboxes will trigger the reopened/inactivity banner for this agent.",
"ALL_INBOXES": "All inboxes",
"PICK_INBOXES": "Select inboxes",
"NONE_WARNING": "No inbox selected — this agent will not see the aggressive alert."
}
},
"SEARCH": {

View File

@ -94,6 +94,13 @@
"ADMIN_SUCCESS_MESSAGE": "Um e-mail com instruções de redefinição de senha foi enviado para o agente",
"SUCCESS_MESSAGE": "Senha do agente redefinida com sucesso",
"ERROR_MESSAGE": "Não foi possível conectar ao servidor Woot, por favor tente novamente mais tarde"
},
"AGGRESSIVE_ALERT": {
"LABEL": "Alerta agressivo — caixas de entrada",
"DESCRIPTION": "Escolha em quais caixas de entrada este agente verá o banner de conversa reaberta e de inatividade.",
"ALL_INBOXES": "Em todas as caixas de entrada",
"PICK_INBOXES": "Selecione as caixas de entrada",
"NONE_WARNING": "Nenhuma caixa selecionada — este agente não verá o alerta agressivo."
}
},
"SEARCH": {

View File

@ -6,6 +6,7 @@ import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Button from 'dashboard/components-next/button/Button.vue';
import Multiselect from 'vue-multiselect';
import Auth from '../../../../api/auth';
import wootConstants from 'dashboard/constants/globals';
@ -38,6 +39,10 @@ const props = defineProps({
type: Number,
default: null,
},
uiSettings: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['close']);
@ -52,6 +57,27 @@ const agentAvailability = ref(props.availability);
const selectedRoleId = ref(props.customRoleId || props.type);
const agentCredentials = ref({ email: props.email });
// --- Alerta agressivo por inbox -------------------------------------------
// ui_settings.aggressive_alert_inbox_ids:
// null/undefined todas (default, legado)
// [] nenhuma (silenciou tudo)
// [1, 2, 3] só essas
const initialInboxIds = props.uiSettings?.aggressive_alert_inbox_ids;
const alertAllInboxes = ref(
initialInboxIds === null ||
initialInboxIds === undefined ||
!Array.isArray(initialInboxIds)
);
const inboxes = useMapGetter('inboxes/getInboxes');
const selectedAlertInboxes = ref(
Array.isArray(initialInboxIds) && inboxes.value
? inboxes.value.filter(i =>
initialInboxIds.map(id => Number(id)).includes(Number(i.id))
)
: []
);
const rules = {
agentName: { required, minLength: minLength(1) },
selectedRoleId: { required },
@ -135,6 +161,12 @@ const editAgent = async () => {
payload.custom_role_id = null;
}
payload.ui_settings = {
aggressive_alert_inbox_ids: alertAllInboxes.value
? null
: selectedAlertInboxes.value.map(i => Number(i.id)),
};
await store.dispatch('agents/update', payload);
useAlert(t('AGENT_MGMT.EDIT.API.SUCCESS_MESSAGE'));
emit('close');
@ -204,6 +236,47 @@ const resetPassword = async () => {
</label>
</div>
<div class="w-full">
<div class="mt-2 border-t pt-3 border-n-weak">
<span class="block font-medium mb-1">
{{ $t('AGENT_MGMT.EDIT.AGGRESSIVE_ALERT.LABEL') }}
</span>
<p class="text-xs text-n-slate-11 mb-2">
{{ $t('AGENT_MGMT.EDIT.AGGRESSIVE_ALERT.DESCRIPTION') }}
</p>
<label class="flex items-center gap-2 mb-2">
<input v-model="alertAllInboxes" type="checkbox" class="!m-0" />
<span>
{{ $t('AGENT_MGMT.EDIT.AGGRESSIVE_ALERT.ALL_INBOXES') }}
</span>
</label>
<div v-if="!alertAllInboxes">
<Multiselect
v-model="selectedAlertInboxes"
:options="inboxes || []"
track-by="id"
label="name"
multiple
:close-on-select="false"
:clear-on-select="false"
hide-selected
:placeholder="$t('AGENT_MGMT.EDIT.AGGRESSIVE_ALERT.PICK_INBOXES')"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
:deselect-label="$t('FORMS.MULTISELECT.ENTER_TO_REMOVE')"
/>
<p
v-if="selectedAlertInboxes.length === 0"
class="text-xs text-n-slate-11 mt-1"
>
{{ $t('AGENT_MGMT.EDIT.AGGRESSIVE_ALERT.NONE_WARNING') }}
</p>
</div>
</div>
</div>
<div class="flex flex-row justify-start w-full gap-2 px-0 py-2">
<div class="w-[50%] ltr:text-left rtl:text-right">
<Button

View File

@ -266,6 +266,7 @@ const confirmDeletion = () => {
:email="currentAgent.email"
:availability="currentAgent.availability_status"
:custom-role-id="currentAgent.custom_role_id"
:ui-settings="currentAgent.ui_settings || {}"
@close="hideEditPopup"
/>
</woot-modal>

View File

@ -11,4 +11,5 @@ json.custom_attributes resource.custom_attributes if resource.custom_attributes.
json.name resource.name
json.role resource.role
json.thumbnail resource.avatar_url
json.ui_settings resource.ui_settings
json.custom_role_id resource.current_account_user&.custom_role_id if ChatwootApp.enterprise?