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 end
def update 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) @agent.current_account_user.update!(agent_params.slice(*account_user_attributes).compact)
end end
@ -72,13 +74,19 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
end end
def allowed_agent_params 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 end
def agent_params def agent_params
params.require(:agent).permit(allowed_agent_params) params.require(:agent).permit(allowed_agent_params)
end 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 def new_agent_params
params.require(:agent).permit(:email, :name, :role, :availability, :auto_offline) params.require(:agent).permit(:email, :name, :role, :availability, :auto_offline)
end end

View File

@ -17,7 +17,18 @@ export default {
...mapGetters({ ...mapGetters({
currentAccountId: 'getCurrentAccountId', currentAccountId: 'getCurrentAccountId',
allConversations: 'getAllConversations', 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() { hasAlerts() {
return this.alerts.length > 0; return this.alerts.length > 0;
}, },
@ -78,7 +89,14 @@ export default {
// quando o usuário só abriu a aba sem receber mensagem ao vivo. // quando o usuário só abriu a aba sem receber mensagem ao vivo.
allConversations: { allConversations: {
handler(conversations) { 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, immediate: true,
}, },

View File

@ -133,12 +133,14 @@ class ActionCableConnector extends BaseActionCableConnector {
const isIncoming = messageType === 0 || messageType === 'incoming'; const isIncoming = messageType === 0 || messageType === 'incoming';
const conversationStatus = conversation && conversation.status; const conversationStatus = conversation && conversation.status;
if (isIncoming && conversationStatus === 'open') { if (isIncoming && conversationStatus === 'open') {
const inboxId = conversation && conversation.inbox_id;
if (!this.isInboxAllowedForUser(inboxId)) return;
const contactName = const contactName =
conversation && conversation.meta && conversation.meta.sender conversation && conversation.meta && conversation.meta.sender
? conversation.meta.sender.name ? conversation.meta.sender.name
: ''; : '';
const inbox = this.app.$store.getters['inboxes/getInbox'] 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; : null;
const inboxName = inbox && inbox.name ? inbox.name : ''; const inboxName = inbox && inbox.name ? inbox.name : '';
inactivityAlertTracker.onClientMessage({ inactivityAlertTracker.onClientMessage({
@ -174,6 +176,22 @@ class ActionCableConnector extends BaseActionCableConnector {
return accountEnabled && userEnabled; 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 // eslint-disable-next-line class-methods-use-this
onReload = () => window.location.reload(); onReload = () => window.location.reload();
@ -194,6 +212,7 @@ class ActionCableConnector extends BaseActionCableConnector {
maybeTriggerAggressiveAlert = data => { maybeTriggerAggressiveAlert = data => {
if (!data || data.status !== 'open') return; if (!data || data.status !== 'open') return;
if (!this.isAggressiveAlertEnabled()) return; if (!this.isAggressiveAlertEnabled()) return;
if (!this.isInboxAllowedForUser(data.inbox_id)) return;
const store = this.app.$store; const store = this.app.$store;
const contactName = const contactName =
data.meta && data.meta.sender ? data.meta.sender.name : ''; 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", "ADMIN_SUCCESS_MESSAGE": "An email with reset password instructions has been sent to the agent",
"SUCCESS_MESSAGE": "Agent password reset successfully", "SUCCESS_MESSAGE": "Agent password reset successfully",
"ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later" "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": { "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", "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", "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" "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": { "SEARCH": {

View File

@ -6,6 +6,7 @@ import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import Multiselect from 'vue-multiselect';
import Auth from '../../../../api/auth'; import Auth from '../../../../api/auth';
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
@ -38,6 +39,10 @@ const props = defineProps({
type: Number, type: Number,
default: null, default: null,
}, },
uiSettings: {
type: Object,
default: () => ({}),
},
}); });
const emit = defineEmits(['close']); const emit = defineEmits(['close']);
@ -52,6 +57,27 @@ const agentAvailability = ref(props.availability);
const selectedRoleId = ref(props.customRoleId || props.type); const selectedRoleId = ref(props.customRoleId || props.type);
const agentCredentials = ref({ email: props.email }); 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 = { const rules = {
agentName: { required, minLength: minLength(1) }, agentName: { required, minLength: minLength(1) },
selectedRoleId: { required }, selectedRoleId: { required },
@ -135,6 +161,12 @@ const editAgent = async () => {
payload.custom_role_id = null; 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); await store.dispatch('agents/update', payload);
useAlert(t('AGENT_MGMT.EDIT.API.SUCCESS_MESSAGE')); useAlert(t('AGENT_MGMT.EDIT.API.SUCCESS_MESSAGE'));
emit('close'); emit('close');
@ -204,6 +236,47 @@ const resetPassword = async () => {
</label> </label>
</div> </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="flex flex-row justify-start w-full gap-2 px-0 py-2">
<div class="w-[50%] ltr:text-left rtl:text-right"> <div class="w-[50%] ltr:text-left rtl:text-right">
<Button <Button

View File

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

View File

@ -11,4 +11,5 @@ json.custom_attributes resource.custom_attributes if resource.custom_attributes.
json.name resource.name json.name resource.name
json.role resource.role json.role resource.role
json.thumbnail resource.avatar_url 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? json.custom_role_id resource.current_account_user&.custom_role_id if ChatwootApp.enterprise?