Skip to main content

Aspectos Técnicos

Enums canônicos​

O sistema centraliza os valores de domínio do módulo de usuários em três enums. Ao trabalhar com tipos de usuário, status ou hierarquia, sempre utilize esses enums em vez de números hardcoded — isso garante legibilidade e evita inconsistências caso os valores mudem.

EnumCaminhoUso
UserTypeIdenum/user-type-id.jsTipos de usuário (1–5)
UserStatusenum/user-status.jsStatus de usuário (0–12)
rolesHierarchyenum/roles-hierarchy.jsMapeamento de quem pode gerenciar quem

O UserStatus expõe dois grupos utilitários que facilitam verificações de estado sem comparações numéricas explícitas:

// enum/user-status.js
PAUSE_STATUSES: [6, 8, 9] // status de pausa ativa (em pausa, em pausa voz, em pausa texto)
DELAYED_PAUSE_STATUSES: [7, 10, 11] // status de pré-pausa (pausa solicitada, aguardando fim do atendimento)

Em vez de if (status === 6 || status === 8 || status === 9), use USER_STATUS.PAUSE_STATUSES.includes(status).


Tabela user_current_status​

Contexto​

Antes desta tabela existir, o status de cada operador era armazenado em um único campo attendance_status na tabela users. Esse campo era atualizado de forma assíncrona por um consumer RabbitMQ — o que criava dois problemas: latência entre o evento e a atualização do status, e impossibilidade de representar estados diferentes por canal, já que um campo só comporta um valor.

A tabela user_current_status resolve ambos os problemas. Ela é atualizada de forma síncrona e direta, e sua estrutura foi pensada para armazenar o status de cada operador separadamente por canal (texto e voz). Hoje é a única fonte de verdade para o status atual de cada operador.

Estrutura​

CREATE TABLE public.user_current_status (
user_id INT4 NOT NULL,
client_id INT4 NOT NULL,
status_id INT4 NOT NULL, -- FK → user_status(id)
break_id BIGINT NULL, -- FK → breaks(id), preenchido quando em pausa
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
status_type user_current_status_type_enum NOT NULL, -- 'TEXT' ou 'VOICE'

PRIMARY KEY (user_id, status_type)
);

Chave primária dual​

O ponto central do design desta tabela é sua chave primária composta: (user_id, status_type). Isso significa que cada operador possui exatamente dois registros na tabela ao mesmo tempo — um para o canal de texto e outro para o canal de voz — e eles são completamente independentes entre si.

RegistroSignificado
(user_id, 'TEXT')Status do operador no canal de texto
(user_id, 'VOICE')Status do operador no canal de voz

Na prática, isso permite cenários como um operador estar Em pausa texto (9) no canal TEXT enquanto está Em chamada ativa (3) no canal VOICE simultaneamente — algo impossível no modelo anterior com um único campo.

Índices​

Os índices abaixo foram criados para otimizar as consultas mais comuns — filtrar operadores disponíveis por cliente, por status ou por canal:

ÍndiceColunaFinalidade
user_current_status_pk(user_id, status_type)Chave primária, garante unicidade por usuário/canal
user_current_status_client_id_idxclient_idConsultas por cliente (ex: listar operadores de um cliente)
user_current_status_status_id_idxstatus_idFiltro por status (ex: buscar operadores ociosos)
user_current_status_break_id_idxbreak_idRelacionamento com pausas
user_current_status_started_at_idxstarted_atOrdenação e filtro temporal
user_current_status_status_type_idxstatus_typeFiltro por canal TEXT/VOICE

Como atualizar o status de um operador​

O único ponto de entrada para escrita na user_current_status é o método updateCurrentUserStatus(), no serviço UserStatus localizado em component/user-status-maintenance/user-status.js. Qualquer parte do sistema que precise alterar o status de um operador deve chamar esse método — nunca escrever diretamente na tabela.

Isso é importante por dois motivos: o método gerencia um distributed lock Redis que evita atualizações simultâneas conflitantes, e centraliza o log de auditoria em um único ponto, independentemente de quem originou a mudança.

Parâmetros​

ParâmetroTipoObrigatórioDescrição
userIdintSimID do operador
statusIdintSimID do novo status (FK → user_status)
clientIdintSimID do cliente ao qual o operador pertence
breakIdintNãoID da pausa associada. Deve ser informado ao entrar em pausa e null ao sair.
statusStartTimetimestampNãoHorário de início do status. Útil quando o momento real do evento difere do momento da escrita. Padrão: now().
statusTypeToUpdate'TEXT' | 'VOICE'NãoDefine qual canal atualizar. Quando omitido, ambos os canais (TEXT e VOICE) são atualizados com o mesmo status.
updateEvenDisconnectedbooleanNãoPor padrão, o sistema não sobrescreve um status DISCONNECTED (0) — isso evita atualizar um operador que acabou de sair. Passe true apenas quando a intenção for deliberadamente atualizar sobre um registro desconectado, como no login.

Proteção por distributed lock​

Antes de escrever na tabela, o serviço tenta adquirir um lock no Redis para cada canal que será atualizado:

lock:user:{userId}:status:TEXT
lock:user:{userId}:status:VOICE

O lock funciona como uma trava: se dois processos tentarem atualizar o status do mesmo operador no mesmo canal ao mesmo tempo, apenas o primeiro passa. O segundo detecta que o lock já está ocupado, descarta sua atualização e registra um aviso no log. Isso evita race conditions em cenários de alta concorrência — como um operador recebendo vários eventos de status em sequência rápida.

Os locks são sempre liberados no bloco finally, garantindo que nenhum lock fique preso caso ocorra um erro durante a atualização.

Query de persistência​

A escrita na tabela usa INSERT ... ON CONFLICT DO UPDATE — o que garante que a operação seja atômica: se o registro já existe (o que é o caso na maioria das vezes, já que cada operador sempre tem dois registros), ele é atualizado; se não existe, é criado.

INSERT INTO user_current_status (user_id, client_id, status_type, status_id, started_at)
SELECT $3::int, $4::int, channels.status_type, $1::int, COALESCE($2, now())
FROM unnest($5::user_current_status_type_enum[]) AS channels(status_type)
ON CONFLICT (user_id, status_type)
DO UPDATE SET
status_id = EXCLUDED.status_id,
started_at = EXCLUDED.started_at
WHERE
-- só atualiza se o status realmente mudou (evita escritas desnecessárias)
user_current_status.status_id IS DISTINCT FROM EXCLUDED.status_id
-- não sobrescreve DISCONNECTED, a menos que updateEvenDisconnected=true
AND user_current_status.status_id != 0
RETURNING user_id, status_type;

A cláusula WHERE no DO UPDATE tem dois papéis: evitar escritas no banco quando o status não mudou (o que seria um trabalho desnecessário), e proteger o registro DISCONNECTED (0) de ser sobrescrito inadvertidamente por eventos tardios que chegam depois do logout.


Estratégia de status no login​

Ao concluir o login, o sistema precisa definir o status inicial do operador na user_current_status. Para isso, usa o objeto USER_STATUS_STRATEGY, definido em component/user-authorization/entity/user-status-strategy.js, que mapeia o grupo do tipo de usuário para os status iniciais de cada canal:

const USER_STATUS_STRATEGY = {
'ADMIN': { text: IDLE, voice: IDLE },
'MANAGER': { text: IN_ATTENDANCE, voice: IDLE }, // GESTOR e SUPERVISOR
'OPERATOR': { text: IN_ATTENDANCE, voice: IDLE } // OPERADOR
};

Essa estratégia só é aplicada quando há chats ativos no momento do login. Se não há chats, todos os canais recebem IDLE (1) independentemente do perfil.

A chamada usa updateEvenDisconnected: true porque o registro existente na tabela estará como DISCONNECTED (0) — deixado pelo logout anterior — e precisamos sobrescrevê-lo deliberadamente ao iniciar uma nova sessão.


Proteção de logout com chats ativos​

Localizado em component/user-authorization/user-authorization.js, método logoutUserById.

Quando o bloqueio é ativado (operador com chats abertos tentando sair voluntariamente), o sistema não apenas retorna erro — ele também grava uma chave no Redis como sinalização para outros processos que possam precisar saber que aquele logout foi bloqueado:

logout_blocked:user:{userId} → TTL: 1800 segundos (30 minutos)

O TTL de 30 minutos existe como salvaguarda: se por algum motivo o processo que leu o bloqueio não fizer a limpeza da chave, ela expira automaticamente e não fica presa indefinidamente.

A verificação de bloqueio só ocorre em logouts voluntários (logoutByDisconnect = false). Logout por desconexão de rede, logout forçado por superiores e logout em massa ignoram completamente essa verificação.

Quem pode deslogar outro usuário​

Para acionar o logout de um usuário diferente do logado, o solicitante precisa ser de um dos tipos:

const rolesThatCanLogout = [UserTypeId.SMARTNX, UserTypeId.GESTOR, UserTypeId.ADMIN, UserTypeId.SUPERVISOR];

Logout automático por inatividade de sessão​

Controlado pela feature flag use-user-session. Quando habilitada, o sistema usa o BullMQ — uma fila de jobs assíncronos com suporte a agendamento — para programar o logout do usuário com antecedência no momento do login. Se o usuário não renovar sua sessão dentro do prazo, o job executa e o desloga automaticamente.

O prazo padrão é de 12 horas, configurável via variável de ambiente AUTO_LOGOUT_DELAY_MS.

Como o sistema evita deslogar sessões ativas​

O problema de um job agendado é que ele pode executar sobre uma sessão que já foi renovada — deslogando um usuário que estava ativo. Para evitar isso, o sistema associa cada job a um identificador único de sessão (sessionId, um UUID) gravado no Redis:

auto_logout:session:{userId} → sessionId (UUID)

Quando o job executa, ele compara o sessionId que recebeu no momento do agendamento com o valor atual no Redis. Se forem diferentes, significa que a sessão foi renovada desde que o job foi criado — e o logout é abortado.

Ciclo de vida do job​

  • Login e selectClient: qualquer job anterior é removido e um novo é agendado com o prazo configurado. Isso garante que o timer sempre reflita a última atividade do usuário.
  • Refresh de token: o job é reagendado do zero, reiniciando o prazo. Usuários que renovam o token periodicamente nunca chegam a ser deslogados automaticamente.
  • Logout voluntário: todos os jobs pendentes do usuário são cancelados imediatamente, já que o logout já ocorreu e o job não tem mais razão de existir.

Quando o job efetivamente executa, o comportamento é idêntico ao logout por desconexão: o status do operador vai para DISCONNECTED (0) em TEXT e VOICE, e a chave de permissões em cache (user_permissions:{userId}) é invalidada.