Skip to main content

Chat interno (features/internal-chat)

Mensajería entre agentes del mismo tenant: conversaciones directas (1 a 1) y grupos, con presencia en línea, indicador de escritura, menciones, archivos, mensajes destacados y notificaciones en el encabezado. Es independiente del chat de tickets (Chat de tickets): usa su propio estado NgRx (internalChat), su propio socket (InternalChatSocketService) y el prefijo de eventos im:. El backend no tiene página de módulo propia en esta documentación; los casos de prueba están en Chat interno (casos) y los destacados en Starred message. El resumen de eventos del socket está también en Sockets.

Rutas y permisos​

CapaArchivoDefinición
Raízshared/routes/content.routes.tsinternal-chat (PAGE_URLS.INTERNAL_CHAT) con canActivate: [AuthGuard], carga InternalChatModule
Sub-rutainternal-chat-routing.module.ts'' redirige a conversations; conversations con permissionGuard y INTERNAL_CHAT_READ, carga ConversationsModule
Páginaconversations/conversations-routing.module.tsRuta vacía a InternalChatPageComponent con permissionGuard y INTERNAL_CHAT_READ

InternalChatModule solo importa el routing; todos los componentes se declaran en ConversationsModule. La URL efectiva es internal-chat/conversations (PAGE_URLS.INTERNAL_CHAT_CONVERSATIONS), que es también el path del menú lateral (id: 'internal-chats', internalChat.sidebar.title). El ítem de menú no declara permiso: un comentario del código indica que las lecturas de InternalConversation son de autoservicio (solo participantes) en el backend. La ruta, en cambio, sí exige INTERNAL_CHAT_READ.

AcrónimoEfecto
INTERNAL_CHAT_READRuta (dos guards anidados con el mismo permiso)
INTERNAL_CHAT_UPDATEEn GroupInfoPanelComponent: botón de editar grupo, guardar cambios, agregar miembros y quitar miembros (*appShowWithPermission). Además se requiere canManageGroup en los casos de editar, agregar y quitar

canManageGroup es verdadero si el usuario es administrador del tenant (userType === 'admin') o tiene rol admin en el grupo (ConversationMemberRole).

Archivos​

ArchivoRol
pages/internal-chat-page/*Contenedor: barra de estado del socket, lista y hilo; maneja deep links ?conversationId=&highlightMessageId=
components/conversation-list/* y conversation-list-item/*Lista paginada con búsqueda (debounce de 350 ms), indicador de presencia y no leídos
components/conversation-thread/*Hilo, compositor, menciones, escritura, archivos, destacados y salto a mensaje
components/new-chat-modal/*Elegir un agente y abrir/crear la conversación directa
components/create-group-modal/*Formulario de grupo con foto y selección múltiple de miembros
components/group-info-panel/*Ver y editar grupo, agregar/quitar miembros, abandonar
components/agent-multi-select-list/*Selector reutilizable de agentes (mode single o multi, filtro por departamento, búsqueda)
components/starred-messages-panel/*Modal con los mensajes destacados de una conversación
conversations/*, internal-chat.module.ts, internal-chat-routing.module.tsMódulos y routing

Fuera de la feature:

  • core/services/api/internal-chat.service.ts (REST)
  • core/services/ui/internal-chat-socket.service.ts y internal-chat-notification.service.ts
  • core/helpers/global/internal-chat-socket.constants.ts (IM_SOCKET_EVENTS)
  • core/states/internal-chat/* (acciones, efectos, reducer, selectores)
  • core/interfaces/api/internal-chat.interface.ts, core/interfaces/ui/internal-chat.interface.ts y internal-chat-socket.interface.ts

Servicios y endpoints​

InternalChatService (providedIn: 'root') usa la base environment.apiUrl + /internal-conversations.

MétodoPeticiónLo invoca
getConversations(filter)GET /internal-conversations (page, limit, type, search)Efecto loadConversations / loadMoreConversations (20 por página)
getConversationById(id)GET /internal-conversations/:idSin uso en efectos de esta rama
getAgents(filter)GET /internal-conversations/agents (departmentId, search, onlineOnly)Efecto loadAgents
createDirectConversation({ targetUserId })POST /internal-conversations/directEfecto createDirectConversation
createGroupConversation(data)POST /internal-conversations/groupEfecto createGroupConversation
updateGroup(id, data)PATCH /internal-conversations/:idEfecto updateGroupConversation
getMembers(id)GET /internal-conversations/:id/membersEfecto loadMembers
addMembers(id, userIds)POST /internal-conversations/:id/membersEfecto addMembers
removeMember(id, userId)DELETE /internal-conversations/:id/members/:userIdEfecto removeMember
leaveConversation(id)POST /internal-conversations/:id/leaveEfecto leaveConversation
markRead(id)POST /internal-conversations/:id/readEfecto markConversationRead
getMessages(id, { page, limit })GET /internal-conversations/:id/messagesEfecto loadMessages / loadMoreMessages
sendMessage(id, data)POST /internal-conversations/:id/messagesNo lo usa el hilo: los mensajes se envían por socket (ver más abajo)

Los destacados usan StarredMessageService con messageType: 'internal' (toggle, findByInternalConversation, fetchMessageWindow); ver Perfil para la lista global.

Modelos​

core/interfaces/api/internal-chat.interface.ts:

TipoCampos o valores
ConversationTypedirect, group
ConversationMemberRoleadmin, member
InternalMessageContentTypetext, file, system
SystemEventTypemember_added, member_removed, left
InternalConversation_id, type, name?, description?, photoUrl?, createdBy?, lastMessageAt?, lastMessagePreview?, lastMessageSenderId?, unreadCount, otherParticipant? (solo en direct: userId, username, nombres, userType, isOnline)
InternalConversationMemberuserId, username, nombres, userType, role, lastReadAt?, joinedAt, isOnline
InternalMessage_id, conversationId, senderId, sender?, content?, contentType, fileId?, file?, mentions?, systemEvent?, actorName?, targetNames?, createdAt
InternalAgent_id, username, nombres, email?, userType, isOnline

sender y file solo vienen resueltos en lecturas de lista (GET .../messages), no en la respuesta cruda de crear. Los nombres de los eventos de sistema (actorName, targetNames) los resuelve el servidor; el frontend no los busca en el store de miembros.

Estado NgRx (internalChat)​

El slice internalChat (InternalChatState, registrado en core/states/app.state.ts junto a InternalChatEffects) guarda:

  • La lista de conversaciones, con página actual, hasMore, filtro activo y banderas de carga (isLoading, isInitialLoad, isLoadingMore).
  • Los agentes.
  • La conversación activa (activeConversationId), sus miembros y sus mensajes con paginación.
  • onlineUserIds.

Reglas del reducer:

  • setActiveConversation limpia miembros y mensajes de la conversación anterior.
  • newMessageFromSocket solo agrega el mensaje si su conversación es la activa y deduplica por _id. El aumento de no leídos y el reordenamiento de la lista no ocurren aquí sino en conversationUpdatedFromSocket (se dispara con cada mensaje), para no contar dos veces. Esa acción mueve la conversación al inicio y suma un no leído si no es la activa y el evento no es de sistema.
  • groupUpdatedFromSocket parchea nombre, descripción y foto en la lista.
  • memberRemovedFromSocket quita al miembro si es la conversación activa; messagesReadFromSocket actualiza lastReadAt del miembro.
  • presenceChangedFromSocket añade o quita el userId de onlineUserIds.

Efectos relevantes (internal-chat.effects.ts): loadConversations usa switchMap y loadMoreConversations usa exhaustMap; memberAddedFromSocket y memberRemovedFromSocket disparan loadMembers si afectan a la conversación activa; conversationCreatedFromSocket recarga la página 1 con el filtro activo. Los toasts de éxito (groupCreated, groupUpdated, membersAdded, memberRemoved, left) y de error salen de los efectos.

Socket (InternalChatSocketService)​

  • Se conecta a <socketUrl>/chat con transporte websocket. ContentComponent (shared/components/layout/content/content.component.ts) llama connect(user._id) al cargar el perfil, despacha loadConversations({ filter: {} }) y llama disconnect() al destruirse.
  • Tras connect emite AUTHENTICATE con sendType: 'agent', el JWT de sesión, userId e idioma; el estado pasa a authenticated al recibir AUTHENTICATED. Los emits (joinConversation, sendMessage, etc.) son no-ops mientras no esté autenticado.
  • Si el servidor corta con io server disconnect reintenta hasta 5 veces con espera creciente de 1 s por intento; cualquier otro motivo lo gestiona socket.io-client.
  • Estados: idle, connecting, authenticated, error. InternalChatPageComponent los muestra con app-connection-status-bar y un botón de reintento.
EventoDirecciónEfecto
im:join-conversation / im:leave-conversationEmitidoEntrar o salir de la sala de la conversación activa
im:send-messageEmitidoEnviar texto o archivo (conversationId, content?, contentType, fileId?, tempId, mentions?)
im:mark-readEmitidoMarcar leída al abrir la conversación
im:typing / im:stop-typingEmitidoIndicador de escritura
im:conversation-joinedRecibidoConfirmación de sala (conversationJoined$)
im:new-messageRecibidoDespacha newMessageFromSocket y emite newMessage$
im:conversation-created, im:conversation-updated, im:group-updated, im:member-added, im:member-removed, im:messages-read, im:presence-changedRecibidoSe traducen a acciones ...FromSocket
im:user-typing / im:user-stop-typingRecibidouserTyping$ y userStopTyping$, consumidos por el hilo

Pantalla (InternalChatPageComponent)​

Compone app-connection-status-bar, app-conversation-list y app-conversation-thread (siempre montado). Con una conversación activa en móvil aparece un botón de volver (closeThreadMobile) que limpia la conversación activa y el query param conversationId.

Deep link desde mensajes destacados globales: ngOnInit lee conversationId y highlightMessageId de la URL; despacha setActiveConversation y, si hay mensaje, _scheduleDeepLinkJump espera (hasta 20 intentos cada 100 ms, unos 2 s) a que el hilo tenga esa conversación activa y llama jumpToMessage; si nunca ocurre, falla en silencio. Los botones de nuevo chat y nuevo grupo abren NewChatModalComponent y CreateGroupModalComponent (size: 'lg', centrados).

Lista de conversaciones​

ConversationListComponent reinicia la lista al iniciar (resetConversations) y carga la primera página. La búsqueda filtra con search; el scroll al fondo (margen de 50 px) pide la siguiente página si hay más y no se está cargando. Una conversación direct se considera en línea si otherParticipant.userId está en onlineUserIds.

Hilo (ConversationThreadComponent)​

Al cambiar la conversación activa: sale de la sala anterior, entra a la nueva (joinConversation), despacha loadMembers, loadMessages y markConversationRead, emite markRead, y carga los destacados de esa conversación para pintar la estrella.

  • Mensajes: combina los del store, una ventana de mensajes (para saltar a un destacado antiguo) y los pendientes optimistas; ordena por createdAt. Solo renderiza los tipos text, file y system; un tipo desconocido se omite sin romper.
  • Envío optimista: sendMessage crea un mensaje temporal (tempId), lo emite por im:send-message y arranca un temporizador de 12 s (SEND_TIMEOUT_MS); si no llega el eco, se marca failed y retrySend lo reenvía con un tempId nuevo. Al llegar im:new-message con ese tempId se descarta el pendiente, aunque la conversación activa sea otra.
  • Escritura: im:typing al empezar a escribir y im:stop-typing tras 2,5 s de inactividad; un temporizador de seguridad de 3 s limpia el indicador del otro usuario.
  • Menciones: al escribir se abre un desplegable de miembros (_updateMentionDropdown); selectMention inserta el nombre y guarda el userId en mentions. El texto pinta las menciones propias y ajenas con estilos distintos.
  • Archivos: onFileSelected valida con FileApiService.validateImage (JPG/PNG/GIF/WEBP, 20 MB) o validateDocument (PDF/DOC/DOCX, 10 MB), sube y emite im:send-message con contentType: 'file' y fileId. La conversación de destino se captura antes de subir.
  • Estado de lectura: en directos muestra sent o read; en grupos cuenta cuántos miembros han visto el mensaje (getSeenByCount, a partir de lastReadAt).
  • Separadores: de fecha y de remitente (agrupación con hueco de 5 min, SENDER_GROUPING_GAP_MS).
  • Mensajes de sistema: member_added, member_removed y left se traducen con claves internalChat.thread.system.*; un tipo no reconocido cae a un texto genérico, no en blanco.
  • Destacados: onToggleFavorite alterna optimistamente y revierte con toast si falla; openStarredPanel abre StarredMessagesPanelComponent; jumpToMessage desplaza al mensaje si ya está renderizado y, si no, pide la ventana al backend con fetchMessageWindow y resalta. No usa el MessageContextMenuComponent del chat de tickets.
  • Grupo: openGroupInfo (solo grupos) abre GroupInfoPanelComponent.

Modales y paneles​

ComponenteComportamiento
NewChatModalComponentCarga agentes (y departamentos si el store está vacío); buscar y filtrar por departamento vuelve a despachar loadAgents. Elegir un agente despacha createDirectConversation; en éxito activa la conversación y cierra. Hay un botón para pasar a crear un grupo
CreateGroupModalComponentFormulario reactivo: name (obligatorio, máximo 80), descripción opcional, foto opcional y memberIds. La foto se valida con validateImage, se previsualiza con un ObjectURL y se sube al elegirla; una generación de subida descarta respuestas de selecciones superadas. En éxito activa el grupo
GroupInfoPanelComponentEdición de nombre (obligatorio, máx. 80), descripción y foto; lista de miembros con insignia de rol y presencia; agregar miembros con AgentMultiSelectListComponent (excluye a los actuales); quitar miembro con confirmación; abandonar con confirmación. La protección isSoleAdmin impide quitar o abandonar si eres el único administrador (toast soleAdminGuard)
StarredMessagesPanelComponentLista paginada (20 por página) con findByInternalConversation, vista previa de texto o archivo, nombre del remitente y botón "cargar más"; emite jumpToMessage al hacer clic

Notificaciones​

InternalChatNotificationService (providedIn: 'root') escucha newMessage$. Si el mensaje no es de la conversación activa muestra un toast, reproduce un sonido (AudioNotificationService.play, o playAssignment en menciones) y, si el audio no está silenciado y el permiso del navegador es granted, lanza una notificación del navegador. Una mención al usuario actual (su _id en mentions) usa un toast de tipo warning con claves internalChat.notifications.mentioned*. Expone notifications$ (conversaciones con no leídos, por fecha) y totalUnread$, que consume el componente de notificaciones del encabezado (shared/components/header/elements/notifications) y cuyo ítem lleva a la conversación (navigateToInternalChat).

Pruebas​

SpecCubre
internal-chat-routing.module.spec.ts, conversations/conversations-routing.module.spec.tsAmbas rutas con permissionGuard e INTERNAL_CHAT_READ
pages/internal-chat-page/internal-chat-page.component.spec.tsDeep link: setActiveConversation, salto cuando el hilo coincide, reintentos acotados y sin salto si falta highlightMessageId
components/conversation-thread/conversation-thread.component.spec.tsTextos de mensajes de sistema, shouldRenderMessage, alternar destacado con rollback y jumpToMessage
components/starred-messages-panel/starred-messages-panel.component.spec.tsCarga, paginación, vista previa, nombre del remitente, iconos de archivo y emisión de salto
core/states/internal-chat/internal-chat.reducers.spec.tsnewMessageFromSocket (mapeo de systemEvent, actorName, targetNames) y conversationUpdatedFromSocket (no suma no leídos si el payload es system: true)

No hay specs para el servicio REST, el socket, los efectos ni los modales de creación y de grupo.

Gotchas​

  • El permiso INTERNAL_CHAT_UPDATE solo se aplica en el panel de grupo; crear conversaciones o grupos y enviar mensajes no tiene *appShowWithPermission.
  • Enviar mensajes es por socket, no por REST: sin autenticación del socket el envío es un no-op y el mensaje acaba marcado como fallido tras 12 s.
  • El contador de no leídos solo se actualiza con im:conversation-updated; im:new-message no lo toca.
  • Los mensajes destacados internos no son los mismos que los de contacto: messageType: 'internal' y salto limitado a la conversación activa.