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
| Capa | Archivo | Definición |
|---|---|---|
| Raíz | shared/routes/content.routes.ts | internal-chat (PAGE_URLS.INTERNAL_CHAT) con canActivate: [AuthGuard], carga InternalChatModule |
| Sub-ruta | internal-chat-routing.module.ts | '' redirige a conversations; conversations con permissionGuard y INTERNAL_CHAT_READ, carga ConversationsModule |
| Página | conversations/conversations-routing.module.ts | Ruta 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ónimo | Efecto |
|---|---|
INTERNAL_CHAT_READ | Ruta (dos guards anidados con el mismo permiso) |
INTERNAL_CHAT_UPDATE | En 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
| Archivo | Rol |
|---|---|
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.ts | Módulos y routing |
Fuera de la feature:
core/services/api/internal-chat.service.ts(REST)core/services/ui/internal-chat-socket.service.tsyinternal-chat-notification.service.tscore/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.tsyinternal-chat-socket.interface.ts
Servicios y endpoints
InternalChatService (providedIn: 'root') usa la base environment.apiUrl + /internal-conversations.
| Método | Petición | Lo invoca |
|---|---|---|
getConversations(filter) | GET /internal-conversations (page, limit, type, search) | Efecto loadConversations / loadMoreConversations (20 por página) |
getConversationById(id) | GET /internal-conversations/:id | Sin uso en efectos de esta rama |
getAgents(filter) | GET /internal-conversations/agents (departmentId, search, onlineOnly) | Efecto loadAgents |
createDirectConversation({ targetUserId }) | POST /internal-conversations/direct | Efecto createDirectConversation |
createGroupConversation(data) | POST /internal-conversations/group | Efecto createGroupConversation |
updateGroup(id, data) | PATCH /internal-conversations/:id | Efecto updateGroupConversation |
getMembers(id) | GET /internal-conversations/:id/members | Efecto loadMembers |
addMembers(id, userIds) | POST /internal-conversations/:id/members | Efecto addMembers |
removeMember(id, userId) | DELETE /internal-conversations/:id/members/:userId | Efecto removeMember |
leaveConversation(id) | POST /internal-conversations/:id/leave | Efecto leaveConversation |
markRead(id) | POST /internal-conversations/:id/read | Efecto markConversationRead |
getMessages(id, { page, limit }) | GET /internal-conversations/:id/messages | Efecto loadMessages / loadMoreMessages |
sendMessage(id, data) | POST /internal-conversations/:id/messages | No 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:
| Tipo | Campos o valores |
|---|---|
ConversationType | direct, group |
ConversationMemberRole | admin, member |
InternalMessageContentType | text, file, system |
SystemEventType | member_added, member_removed, left |
InternalConversation | _id, type, name?, description?, photoUrl?, createdBy?, lastMessageAt?, lastMessagePreview?, lastMessageSenderId?, unreadCount, otherParticipant? (solo en direct: userId, username, nombres, userType, isOnline) |
InternalConversationMember | userId, 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:
setActiveConversationlimpia miembros y mensajes de la conversación anterior.newMessageFromSocketsolo 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 enconversationUpdatedFromSocket(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.groupUpdatedFromSocketparchea nombre, descripción y foto en la lista.memberRemovedFromSocketquita al miembro si es la conversación activa;messagesReadFromSocketactualizalastReadAtdel miembro.presenceChangedFromSocketañade o quita eluserIddeonlineUserIds.
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>/chatcon transportewebsocket.ContentComponent(shared/components/layout/content/content.component.ts) llamaconnect(user._id)al cargar el perfil, despachaloadConversations({ filter: {} })y llamadisconnect()al destruirse. - Tras
connectemiteAUTHENTICATEconsendType: 'agent', el JWT de sesión,userIde idioma; el estado pasa aauthenticatedal recibirAUTHENTICATED. Los emits (joinConversation,sendMessage, etc.) son no-ops mientras no esté autenticado. - Si el servidor corta con
io server disconnectreintenta hasta 5 veces con espera creciente de 1 s por intento; cualquier otro motivo lo gestionasocket.io-client. - Estados:
idle,connecting,authenticated,error.InternalChatPageComponentlos muestra conapp-connection-status-bary un botón de reintento.
| Evento | Dirección | Efecto |
|---|---|---|
im:join-conversation / im:leave-conversation | Emitido | Entrar o salir de la sala de la conversación activa |
im:send-message | Emitido | Enviar texto o archivo (conversationId, content?, contentType, fileId?, tempId, mentions?) |
im:mark-read | Emitido | Marcar leída al abrir la conversación |
im:typing / im:stop-typing | Emitido | Indicador de escritura |
im:conversation-joined | Recibido | Confirmación de sala (conversationJoined$) |
im:new-message | Recibido | Despacha newMessageFromSocket y emite newMessage$ |
im:conversation-created, im:conversation-updated, im:group-updated, im:member-added, im:member-removed, im:messages-read, im:presence-changed | Recibido | Se traducen a acciones ...FromSocket |
im:user-typing / im:user-stop-typing | Recibido | userTyping$ 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 tipostext,fileysystem; un tipo desconocido se omite sin romper. - Envío optimista:
sendMessagecrea un mensaje temporal (tempId), lo emite porim:send-messagey arranca un temporizador de 12 s (SEND_TIMEOUT_MS); si no llega el eco, se marcafailedyretrySendlo reenvía con untempIdnuevo. Al llegarim:new-messagecon esetempIdse descarta el pendiente, aunque la conversación activa sea otra. - Escritura:
im:typingal empezar a escribir yim:stop-typingtras 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);selectMentioninserta el nombre y guarda eluserIdenmentions. El texto pinta las menciones propias y ajenas con estilos distintos. - Archivos:
onFileSelectedvalida conFileApiService.validateImage(JPG/PNG/GIF/WEBP, 20 MB) ovalidateDocument(PDF/DOC/DOCX, 10 MB), sube y emiteim:send-messageconcontentType: 'file'yfileId. La conversación de destino se captura antes de subir. - Estado de lectura: en directos muestra
sentoread; en grupos cuenta cuántos miembros han visto el mensaje (getSeenByCount, a partir delastReadAt). - Separadores: de fecha y de remitente (agrupación con hueco de 5 min,
SENDER_GROUPING_GAP_MS). - Mensajes de sistema:
member_added,member_removedyleftse traducen con clavesinternalChat.thread.system.*; un tipo no reconocido cae a un texto genérico, no en blanco. - Destacados:
onToggleFavoritealterna optimistamente y revierte con toast si falla;openStarredPanelabreStarredMessagesPanelComponent;jumpToMessagedesplaza al mensaje si ya está renderizado y, si no, pide la ventana al backend confetchMessageWindowy resalta. No usa elMessageContextMenuComponentdel chat de tickets. - Grupo:
openGroupInfo(solo grupos) abreGroupInfoPanelComponent.
Modales y paneles
| Componente | Comportamiento |
|---|---|
NewChatModalComponent | Carga 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 |
CreateGroupModalComponent | Formulario 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 |
GroupInfoPanelComponent | Edició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) |
StarredMessagesPanelComponent | Lista 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
| Spec | Cubre |
|---|---|
internal-chat-routing.module.spec.ts, conversations/conversations-routing.module.spec.ts | Ambas rutas con permissionGuard e INTERNAL_CHAT_READ |
pages/internal-chat-page/internal-chat-page.component.spec.ts | Deep link: setActiveConversation, salto cuando el hilo coincide, reintentos acotados y sin salto si falta highlightMessageId |
components/conversation-thread/conversation-thread.component.spec.ts | Textos de mensajes de sistema, shouldRenderMessage, alternar destacado con rollback y jumpToMessage |
components/starred-messages-panel/starred-messages-panel.component.spec.ts | Carga, paginación, vista previa, nombre del remitente, iconos de archivo y emisión de salto |
core/states/internal-chat/internal-chat.reducers.spec.ts | newMessageFromSocket (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_UPDATEsolo 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-messageno lo toca. - Los mensajes destacados internos no son los mismos que los de contacto:
messageType: 'internal'y salto limitado a la conversación activa.