Acciones del ticket
Todo lo que un agente puede hacer sobre un ticket desde la pantalla de Chat de tickets: los 15 identificadores del menú (TicketMenuActionId) y las 54 acciones NgRx del slice tickets (TicketActions) que los respaldan. Cada acción se documenta con su efecto, endpoint, permiso (en el cliente y en el backend) y reglas. Los endpoints se verificaron contra ticket.controller.ts y ticket.service.ts del backend; ver también Ticket (su tabla de endpoints puede no listar estas rutas), Message y System message.
Flujo de una acción
menú (tarjeta, cabecera o pop-out) -> TicketMenuActionId
-> ChatListComponent.onTicketAction (confirmaciones, guardia de bloqueo, paneles)
-> TicketActions.<accion> (NgRx)
-> TicketEffects -> TicketService (PATCH o DELETE /tickets/...)
-> <accion>Success -> reducer (quita o parchea la fila) + toast
<- eco del socket (ticket_status_changed, ticket_assigned, ticket_lock_changed, ticket_deleted, ticket_reactivated)
La lista (ChatListComponent) es quien interpreta el identificador: la tarjeta, la cabecera de la conversación y el pop-out solo emiten el TicketMenuActionId. Los errores genéricos (...Failure) los muestra actionFailure$ con el toast tickets.toast.error; el conflicto de bloqueo tiene tratamiento propio.
Archivos
| Archivo | Rol |
|---|---|
components/chat/ticket-actions-menu/ticket-actions-menu.component.* | Menú desplegable (overlay CDK) con las opciones y su visibilidad |
components/chat/chat-list/chat-list.component.ts | onTicketAction, _resolveWithLabelGuard, _blockIfLocked, confirmaciones, paneles |
components/chat/transfer-panel/transfer-panel.component.* | Panel de transferencia |
components/chat/postpone-dialog/postpone-dialog.component.* y .service.ts | Diálogo de posposición (standalone) |
core/states/ticket/ticket.actions.ts, .effects.ts, .reducers.ts, .selectors.ts | Acciones, efectos, reducer y selectores |
core/services/api/ticket.service.ts | Llamadas HTTP |
core/helpers/global/ticket.contants.ts | PAGE_SIZE, TOAST, STATUS_LABELS, TICKET_ERROR_CODES |
Visibilidad por contexto
TicketActionsMenuComponent recibe ticketStatus, canTransfer, lockedBy, isOwner e isLockedByMe. Reglas del menú:
| Opción | Se muestra si | Permiso en el cliente |
|---|---|---|
| Resolver, Resolver sin despedida, Devolver | El ticket no está resolved, closed ni cancelled | TICKET_UPDATE |
Mantener conmigo (lock) | Sin bloqueo (lockedBy vacío) y el usuario es el dueño (isOwner), además de lo anterior | TICKET_UPDATE |
Liberar (unlock) | Hay bloqueo y lo tiene el propio usuario (isLockedByMe), además de lo anterior | TICKET_UPDATE |
| Transferir | canTransfer y lo anterior | TICKET_UPDATE |
| Posponer | Estado distinto de postponed y lo anterior | TICKET_UPDATE |
| Cancelar posposición | Estado postponed y lo anterior | TICKET_UPDATE |
| Marcar como no leído | Siempre | Ninguno |
| Motivos de contacto | Siempre | Ninguno |
| Cambiar estado | El botón siempre; los cuatro estados dentro | TICKET_UPDATE sobre los estados |
| Borrar | Siempre | TICKET_DELETE |
canTransfer lo calcula la lista: administrador (userType === 'admin') o dueño del ticket (assignedUserId igual al usuario). La tarjeta y el pop-out lo pasan; la cabecera de la conversación no lo enlaza y usa el valor por defecto (true), así que allí "Transferir" aparece siempre, y es el backend quien rechaza a quien no corresponda. El botón Resolver de la cabecera no tiene directiva de permiso. La vista de solo lectura no incluye menú.
Las 15 acciones del menú
Resolver (resolve)
- Qué hace: cierra el ticket como resuelto y envía la despedida del canal.
- Cliente:
_resolveWithLabelGuard. Si el contacto tiene etiquetas, despacha de inmediato; si no tiene ninguna, pide confirmación ("Resolver sin etiquetas"). No comprueba bloqueo. En el pop-out cierra la ventana al despachar. - Acción y endpoint:
resolveTicket->PATCH /tickets/resolve/:id, cuerpo vacío. - Backend (
TICKET_UPDATE): envía la despedida de conexión (cadena conexión, tenant, genérico),markResolved(guardaresolvedAt, que mide la ventana de reapertura), cancela los mensajes programados pendientes (motivoticket_resolved, no bloqueante) y registra el mensaje interno "Ticket resuelto por ...". Difundeticket_status_changedconresolved. - Estado local:
resolveTicketSuccessquita la fila si la pestaña activa no esresolved(removeIfFilterMismatch); toast de éxito.
Resolver sin despedida (resolveNoFarewell)
- Cliente: siempre confirma ("El ticket se resolverá sin enviar el mensaje de despedida"). Mismo cierre de pop-out.
- Acción y endpoint:
resolveNoFarewellTicket->PATCH /tickets/resolve-no-farewell/:id. - Backend (
TICKET_UPDATE): igual que resolver consendFarewell = false. - Estado local: igual que resolver; toast "Ticket resuelto sin despedida".
Devolver (return)
- Qué hace: devuelve el ticket a la cola sin asignar. No reabre un ticket resuelto.
- Cliente: si el ticket está bloqueado, muestra el aviso "Ticket bloqueado" (
_blockIfLocked) y se detiene; si no, confirma ("volverá a la cola sin asignar"). Al confirmar despachaunassignTicket, noreturnTicket, y cierra la conversación si era la abierta. En el pop-out,onPopoutTicketActioncierra la ventana justo después de lanzar la acción (parareturnydelete, antes de que se resuelva la confirmación). - Endpoint:
PATCH /tickets/unassign/:id. - Backend (
TICKET_UPDATE): solo el dueño o un administrador (si no, 403); si el ticket tiene bloqueo responde 409 con el códigoTICKET_LOCKED; solo en estadoopenoinProgress(si no, 400); libera a la cola; registra "Ticket devuelto a la cola por ..."; difundeticket_status_changedconopen. - Estado local:
unassignTicketSuccessquita la fila o la deja comoopensin asignado según el filtro (removeIfUnassignedMismatch) y descuentatotalCountsi la quitó; toast "Ticket devuelto a la cola". Ante el 409TICKET_LOCKED(el bloqueo llegó entre la comprobación y la petición) se despachaticketLockConflicty se muestra el mismo aviso, sin toast de error.
Transferir (transfer)
- Cliente: no disponible si
canTransferes falso; con bloqueo muestra el aviso de desbloqueo. Si no, abreTransferPanelComponentcomo overlay centrado con el agente y el departamento actuales preseleccionados. - Panel: formulario reactivo con
agentId(obligatorio),departmentId(obligatorio, deshabilitado hasta elegir agente),internalNote(opcional) ykeepWithUser(interruptor). Los agentes salen deGET /users/get-all(búsqueda y scroll infinito) y los departamentos deGET /departments/get-allfiltrado poruserIddel agente elegido, de modo que solo se ofrecen los departamentos de los que es miembro. Cambiar de agente limpia y recarga el departamento. Emite el agente, el departamento (con nombre y color) y los opcionales. - Acción y endpoint:
transferTicket->PATCH /tickets/transfer/:idcon{ userId, departmentId, internalNote, keepWithUser }(los dos últimos solo si se rellenan). - Backend (
TICKET_UPDATE): exige agente o departamento (400); permitido al dueño, a un administrador o a cualquiera si el ticket está en cola; con bloqueo responde 409TICKET_LOCKED; si se indican ambos, el agente debe pertenecer al departamento (400); registra el mensaje interno de asignación o de transferencia a departamento, notifica al agente asignado y puede enviar el saludo del departamento. Ver gotcha sobreinternalNoteykeepWithUser. - Estado local:
transferTicketSuccessactualizaassignedUsery el departamento (para repintar el color sin refetch); toast "Ticket transferido al agente".
Posponer (postpone)
- Cliente:
PostponeDialogService.open()abre un modal pequeño y estático con selector de fecha y hora (DateTimePickerComponent, hora inicial: una hora adelante). Solo permite confirmar una fecha futura (isValidFutureSelection); devuelve unDateonullsi se cancela. - Acción y endpoint:
postponeTicket->PATCH /tickets/postpone/:idcon{ postponedUntil }en ISO. - Backend (
TICKET_UPDATE):postponedUntildebe ser ISO y estrictamente futura (@IsFutureDate, se valida al llegar la petición); dueño o administrador (403 si no); pasa el ticket apostponed, registra "Ticket pospuesto por ..." y difundeticket_status_changedconpostponed. Un job reactiva el ticket al vencer (verticket_reactivateden Tiempo real). - Estado local:
postponeTicketSuccesspasa la fila apostponedconpostponedUntil(o la quita si la pestaña es otra); toastwarning.
Cancelar posposición (cancelPostpone)
- Cliente: sin confirmación; la opción reemplaza a "Posponer" cuando el ticket está
postponed. - Acción y endpoint:
cancelPostponeTicket->PATCH /tickets/cancel-postpone/:id. - Backend (
TICKET_UPDATE): solo si estápostponed(400 si no); dueño o administrador; vuelve aopen, registra "Posponer cancelado por ..." y difundeopen. - Estado local: fila a
openconpostponedUntilvacío; toast informativo.
Marcar como no leído (markUnread)
- Cliente: despacha
markUnread({ ticketId, agentUnread: true })sin confirmación ni permiso. - Endpoint:
PATCH /tickets/mark-unread/:idcon{ agentUnread }. - Backend: exige
TICKET_UPDATE; guarda el indicador persistenteagentUnread(independiente deunreadCount). - Estado local:
markUnreadSuccessfijaagentUnread; toast solo cuando estrue. La tarjeta muestra un punto. Al seleccionar el ticket,selectConversationdespachamarkUnreadconfalse.
Motivos de contacto (contactReasons)
- Cliente: no despacha ninguna acción de
TicketActionsni llama a un endpoint al abrirse: abreLabelsPanelComponent(etiquetas y metadatos del contacto) como overlay. Al guardar emiteupdatedy la lista se recarga. Detalle y endpoints en Etiquetas y datos del contacto. - Permisos: ninguno para abrir el panel; sus acciones internas exigen
CONTACT_UPDATEyLABEL_*.
Borrar (delete)
- Cliente: confirma con advertencia de irreversibilidad ("se eliminarán el ticket y todos sus mensajes de forma permanente, y el contacto será notificado"). Si se cancela no hace nada. Al confirmar despacha
deleteTickety cierra la conversación si era la abierta. - Endpoint:
DELETE /tickets/remove/:id. - Backend (
TICKET_DELETE): difunde el cierre al contacto (motivodeleted), expulsa la sala del ticket (el fallo no es bloqueante), cancela los programados pendientes (ticket_deleted), borra todos los mensajes del ticket y por último el ticket. - Estado local:
deleteTicketSuccessquita la fila y descuentatotalCount; el ecoticket_deletedsolo descuenta si la fila seguía presente (idempotente). Toast "Ticket eliminado".
Cambiar estado: statusOpen, statusInProgress, statusClosed, statusCancelled
Cuatro identificadores que despachan la misma acción changeTicketStatus con open, inProgress, closed y cancelled.
- Cliente: submenú "Cambiar estado" sin confirmación; los cuatro ítems requieren
TICKET_UPDATE. No se ofrecenpending,postponedniresolved. - Endpoint:
PATCH /tickets/status/:idcon{ status }. - Backend (
TICKET_UPDATE):ChangeStatusDtoacepta cualquiera de los siete valores; actualiza el estado sin comprobar propiedad, registra un mensaje interno con el estado y el agente (por ejemplo "Ticket cerrado por ...") y difundeticket_status_changed. - Estado local:
changeTicketStatusSuccessquita la fila si el estado no coincide con la pestaña activa; toast "Estado actualizado" con el nombre traducido del estado.
Mantener conmigo (lock)
- Cliente: sin confirmación. La tarjeta muestra un candado y la lista de bloqueados está en Tickets bloqueados.
- Acción y endpoint:
lockTicket->PATCH /tickets/lock/:id. - Backend (
TICKET_UPDATE): solo el dueño actual (403notLockOwner), solo enopenoinProgress; el cambio es atómico (409 si cambió entretanto). El controlador difundeticket_lock_changeda todo el tenant. - Estado local:
lockTicketSuccessguardalockedBy; toast "Ticket bloqueado: lo mantienes contigo". Mientras esté bloqueado no se puede devolver ni transferir.
Liberar (unlock)
- Acción y endpoint:
unlockTicket->PATCH /tickets/unlock/:id. - Backend (
TICKET_UPDATE): solo quien lo bloqueó (403notLockOwner); difundeticket_lock_changedconlockedByvacío. - Estado local:
unlockTicketSuccesslimpialockedByylockedByUser. La liberación administrativa (PATCH /tickets/force-unlock/:id,TICKET_UNLOCK_ANY, sin comprobar quién bloqueó) está fuera de este menú, en Tickets bloqueados. Resolver, cerrar o cancelar también limpia el bloqueo (ticketStatusChangedFromSocketcon estado terminal).
Las 54 acciones de TicketActions
TicketActions (objeto exportado de ticket.actions.ts) agrupa 54 acciones. Filtro activo: activeFilter del estado; "quita la fila" significa filtrar la lista si el estado resultante no coincide con la pestaña activa.
Carga (7)
| # | Acción | Quién la despacha | Efecto |
|---|---|---|---|
| 1 | loadTickets | Lista (dispatchLoadTickets) | Reinicia lista, página 1 y banderas; guarda el filtro; efecto GET /tickets/get-all con page: 1 y limit: 10 (switchMap) |
| 2 | loadTicketsSuccess | Efecto | Guarda tickets, totalCount, hasMore (meta.page < meta.totalPages) y fija currentPage en 2 |
| 3 | loadTicketsFailure | Efecto | Guarda el error y apaga las banderas de carga |
| 4 | loadMoreTickets | Lista, al llegar al fondo | Marca isLoadingMore; efecto GET /tickets/get-all con la página siguiente (exhaustMap) |
| 5 | loadMoreTicketsSuccess | Efecto | Anexa los resultados e incrementa la página |
| 6 | loadMoreTicketsFailure | Efecto | Guarda el error |
| 7 | resetTickets | Nadie en el código de la rama (solo specs) | Devuelve el estado inicial |
Socket (6)
Las despacha ChatSocketService desde los eventos del servidor.
| # | Acción | Evento | Efecto en el reducer |
|---|---|---|---|
| 8 | ticketUpdatedFromSocket | ticket_updated | Si el ticket está en la lista, actualiza lastMessage (id, ack, contenido, tipo, medio, vista única, remitente) y updatedAt y lo sube al primer lugar |
| 9 | ticketAssignedFromSocket | ticket_assigned | Quita la fila si el filtro es "sin asignar" u otro operador; si no, actualiza assignedUser y descarta el avatar anterior si cambió el agente |
| 10 | ticketStatusChangedFromSocket | ticket_status_changed | Quita la fila si el estado no coincide con la pestaña; con estado terminal (resolved, closed, cancelled) limpia lockedBy |
| 11 | ticketReactivatedFromSocket | ticket_reactivated | Fila a open, sin postponedUntil y con reactivated: true (bandera solo de interfaz) |
| 12 | ticketDeletedFromSocket | ticket_deleted | Quita la fila y descuenta totalCount solo si seguía presente |
| 13 | contactPresenceChangedFromSocket | contact_presence_changed | Parchea isOnline, lastSeen y lastDisconnectReason en todas las filas del contacto |
Resolver, devolver, transferir, posponer, borrar, estado, no leído (21)
| # | Acción | Efecto |
|---|---|---|
| 14 | resolveTicket | Efecto PATCH /tickets/resolve/:id (mergeMap) |
| 15 | resolveTicketSuccess | Quita la fila salvo en resolved; toast |
| 16 | resolveTicketFailure | Toast de error genérico |
| 17 | resolveNoFarewellTicket | PATCH /tickets/resolve-no-farewell/:id |
| 18 | resolveNoFarewellTicketSuccess | Igual que 15; toast propio |
| 19 | resolveNoFarewellTicketFailure | Toast de error |
| 20 | returnTicket | PATCH /tickets/return/:id (reabre un ticket resuelto en el backend). Ningún componente la despacha: el botón "Devolver" usa unassignTicket |
| 21 | returnTicketSuccess | Fila a open o quitada según el filtro; toast informativo |
| 22 | returnTicketFailure | Toast de error |
| 23 | unassignTicket | PATCH /tickets/unassign/:id; un 409 TICKET_LOCKED emite ticketLockConflict en lugar del fallo |
| 24 | unassignTicketSuccess | Ver "Devolver"; toast |
| 25 | unassignTicketFailure | Toast de error |
| 26 | transferTicket | PATCH /tickets/transfer/:id; mismo tratamiento del 409 |
| 27 | transferTicketSuccess | Actualiza assignedUser y department; toast |
| 28 | transferTicketFailure | Toast de error |
| 29 | postponeTicket | PATCH /tickets/postpone/:id |
| 30 | postponeTicketSuccess | Fila a postponed con postponedUntil; toast warning |
| 31 | postponeTicketFailure | Toast de error |
| 32 | cancelPostponeTicket | PATCH /tickets/cancel-postpone/:id |
| 33 | cancelPostponeTicketSuccess | Fila a open sin postponedUntil; toast |
| 34 | cancelPostponeTicketFailure | Toast de error |
| 35 | deleteTicket | DELETE /tickets/remove/:id |
| 36 | deleteTicketSuccess | Quita la fila y descuenta totalCount; toast |
| 37 | deleteTicketFailure | Toast de error |
| 38 | changeTicketStatus | PATCH /tickets/status/:id con { status } |
| 39 | changeTicketStatusSuccess | Quita la fila si no coincide; toast con el nombre del estado |
| 40 | changeTicketStatusFailure | Toast de error |
| 41 | markUnread | PATCH /tickets/mark-unread/:id con { agentUnread }; también la despacha selectConversation con false |
| 42 | markUnreadSuccess | Fija agentUnread; toast solo si es true |
| 43 | markUnreadFailure | Toast de error |
No leídos y acuses en tiempo real (3)
| # | Acción | Quién la despacha | Efecto |
|---|---|---|---|
| 44 | ticketIncrementUnread | Lista, al llegar un ticket_updated del contacto de un ticket no abierto (de administradores o de tickets visibles); también suena el aviso de audio | Suma 1 a unreadCount |
| 45 | ticketClearUnread | Lista, al seleccionar un ticket con no leídos o al llegar un acuse read | Pone unreadCount en 0 |
| 46 | ticketLastMessageAckChanged | Lista, con cada message_ack_changed | Actualiza el ack del último mensaje si su id está en la lista de ids |
Bloqueo (8)
| # | Acción | Efecto |
|---|---|---|
| 47 | lockTicket | PATCH /tickets/lock/:id; toma lockedBy de la respuesta |
| 48 | lockTicketSuccess | Guarda lockedBy; toast |
| 49 | lockTicketFailure | Toast de error |
| 50 | unlockTicket | PATCH /tickets/unlock/:id |
| 51 | unlockTicketSuccess | lockedBy y lockedByUser a null; toast |
| 52 | unlockTicketFailure | Toast de error |
| 53 | ticketLockChangedFromSocket | Evento ticket_lock_changed (difundido a todo el tenant): fija lockedBy y lockedByUser (con username si llega el nombre) o los limpia |
| 54 | ticketLockConflict | Se emite en lugar del fallo cuando transferir o devolver reciben 409 TICKET_LOCKED: el efecto muestra el aviso "desbloquea primero"; no está en la lista de actionFailure$ y su reducer no hace nada |
Pruebas
| Spec | Cubre |
|---|---|
ticket-actions-menu.component.spec.ts | canLockTicket (sin bloqueo y dueño), canUnlockTicket (bloqueado por el propio usuario; no si lo tiene otro) y emisión de lock y unlock con select() |
transfer-panel.component.spec.ts | Departamento deshabilitado hasta elegir agente, carga filtrada por membresía, limpieza al cambiar de agente, bloqueo hasta tener agente y departamento, emisión con el departamento elegido |
postpone-dialog.component.spec.ts | Fecha pasada inválida, fecha futura válida, no cierra con fecha pasada, resuelve el Date combinado, cancelar |
chat-list.component.spec.ts | onTicketAction para borrar (confirmación, cierre de la conversación abierta, cancelación), bloquear y desbloquear, guardia "desbloquea primero" al transferir y devolver, isTicketOwner e isLockedByCurrentUser |
chat-popout.component.spec.ts | Enlaces del menú de bloqueo y minimizar |
core/states/ticket/ticket.actions.spec.ts | Creadores de posponer, cancelar posposición, reactivación, bloquear, liberar y cambio de bloqueo |
core/states/ticket/ticket.effects.spec.ts | Efectos de posponer, cancelar posposición, bloquear, liberar, conflicto 409 en transferir y devolver y que ticketLockConflict no dispare el toast genérico |
core/states/ticket/ticket.reducers.spec.ts | Posponer, cancelar, reactivar, eliminar por socket, marcar no leído, presencia, bloqueo y su limpieza por estado terminal, devolver con filtro de operador, asignación por socket y transferir |
No hay specs de efectos ni reducers para resolver, resolver sin despedida, cambiar estado, borrar (efecto) ni carga paginada, y no hay spec de ticket.service.ts.
Gotchas
- "Devolver" llama a
unassign, no areturn:return/:iden el backend reabre un ticket resuelto (y falla si el contacto ya tiene otro ticket activo o si no es el último), pero la acciónreturnTicketno está conectada a la interfaz. - El DTO de transferencia valida
internalNoteykeepWithUser, pero el controlador solo pasauserIdydepartmentIdal servicio; en esta rama esos dos campos no tienen efecto en el backend. - El efecto de transferir lee
res.data.assignedUsery, como el backend devuelve el ticket, recurre a{ _id: userId }: el nombre del asignado se completa luego con el eventoticket_assignedo con la recarga. - "Marcar como no leído" no tiene permiso en el cliente pero el backend exige
TICKET_UPDATE. markUnread, Motivos de contacto y Borrar aparecen también en tickets resueltos o cerrados.- Los textos de la guardia de bloqueo usan voseo (
Liberalo) enwhaticket.ticketActions.lockedFirstMessage.