# Tiempo real (WebSocket)

> El WebSocket de la plataforma - el ticket de un solo uso, el subprotocolo memberfy-realtime, las salas automáticas y las pedidas, los avisos invalidate, los límites y los códigos de cierre.

La app recibe los cambios al momento por un WebSocket: la campana, el chat, el "En línea ahora", quién está en cada pantalla, el banner del evento en directo, los números de las campañas. Cualquier miembro de la comunidad, de cualquier rol, abre el socket, **con el chat activado o desactivado**.

## Abrir la conexión

1. Pide un ticket con `POST /api/realtime/ticket`, con el token y el `X-CommunityId` (ver [Autenticación](/api/autenticacao) y [X-CommunityId](/api/x-community-id)). El ticket es de **un solo uso**, vale **60 segundos** y queda ligado a la persona, a la comunidad y a la sesión.
2. Abre `wss://<api>/api/realtime/socket` (el `data.path`) pasando el ticket como **segundo subprotocolo**, después de `memberfy-realtime` (el `data.protocols`). El servidor responde con `memberfy-realtime`.

```js
const { data } = await api.post('/api/realtime/ticket'); // con Authorization y X-CommunityId
const ws = new WebSocket(`wss://api.memberfy.net${data.path}`, data.protocols);
```

El ticket nunca va en la URL (en la query, se ignora). Sin un ticket válido, la conexión se rechaza antes de convertirse en WebSocket (401). Cambiar de comunidad cierra el socket: pide otro ticket. Sin el servicio de tiempo real, el ticket devuelve `503`, y la app sigue consultando por las rutas.

`POST /api/chat/ticket` y `/api/chat/socket` siguen funcionando como **alias** de estos durante un despliegue y después desaparecen: usa los nuevos.

## Salas

Toda conexión ya entra en tres salas:

| Sala | Quién |
|---|---|
| `me` | La propia persona |
| `community` | Todos en la comunidad |
| `staff` | Solo el equipo (propietario, administrador, moderador y finanzas) |

Las demás las pide el cliente, con la misma regla del `GET` correspondiente, siempre en esta comunidad:

| Sala | Quién entra |
|---|---|
| `space:<id>` | Quien puede abrir el espacio |
| `item:event:<id>` | Quien puede abrir el evento |
| `item:post:<id>` | Quien puede abrir la publicación |

```json
{ "type": "subscribe", "rooms": ["space:5b0e7c1a-…", "item:event:9d2f…"] }
{ "type": "unsubscribe", "rooms": ["space:5b0e7c1a-…"] }
```

También hay salas para los editores, solo para los roles que pueden guardar ahí: `item:course:<id>`, `item:form:<id>` (el equipo y quien gestiona el formulario; un miembro cuando está publicado y abre el espacio), `item:campaign:<id>`, `item:email:<id>` (e-mail de la biblioteca), `item:workflow:<id>`, `item:survey:<id>` (propietario y administrador), `item:certificateTemplate:<id>` (quien emite certificados en el espacio), `item:emailOverride:<emailKey>` (e-mail predeterminado, propietario y administrador). Y las salas de área, una por pantalla de sección: `area:home` (todos), `area:members`, `area:campaigns`, `area:emails`, `area:workflows` (equipo), `area:moderation` (propietario, administrador y moderador), `area:analytics`, `area:dashboard`, `area:settings` (propietario y administrador) y `area:finance` (propietario, administrador y finanzas).

Como máximo **20 salas** por conexión (las automáticas no cuentan). La respuesta es `subscribed` con las salas aceptadas y un `subscribe.denied` por sala rechazada, **sin motivo**. Cuando el acceso de la persona cambia (salió, rol nuevo, grupo, acceso al espacio), el servidor vuelve a evaluar las salas, envía `subscribe.denied` de las que perdió y después `access.changed`.

## Avisos de cambio (`invalidate`)

```json
{ "type": "invalidate", "room": "community", "topic": "event.live", "ids": ["9d2f…"], "at": "2026-10-23T14:05:00.000Z" }
```

El aviso trae **solo los ids y el tema**, nunca título, texto, importe ni nombre: al recibirlo, repite el `GET` de lo que está en pantalla por las rutas de siempre.

| Tema | Sala | Cuándo |
|---|---|---|
| `event.live` | `community`, `space:<id>`, `item:event:<id>` | Un evento empezó o terminó la transmisión |
| `events` | `community`, `space:<id>`, `item:event:<id>` | Un evento se creó, se editó o se eliminó |
| `analytics.online` | `staff` (propietario y administrador) | El "En línea ahora" cambió (como máximo 1 vez cada 5 segundos) |
| `campaigns.stats` | `staff` | Terminó un envío de campaña |
| `menu.changed` | `community` | El menú cambió: sección o espacio creado, editado (nombre, icono, dirección, sección, acceso, visibilidad), eliminado o reordenado. Sin `ids` y sin ningún dato del menú: cada persona vuelve a hacer el `GET /api/sections` con sus propios permisos. Los cambios seguidos se convierten en un solo aviso, en hasta 1 segundo |

Los avisos de evento solo llegan a quien ve ese espacio; un evento privado o en moderación, solo al equipo.

## Quién está aquí

La presencia de área (las caras en la parte superior de cada pantalla, ver [Quién está aquí y edición en equipo](/conceitos/presenca-e-edicao-em-equipe)) usa las mismas salas.

```json
{ "type": "presence.join", "room": "area:campaigns", "editing": false }
{ "type": "presence.leave", "room": "area:campaigns" }
{ "type": "presence.active" }
```

- `presence.join` al abrir la pantalla: la sala se evalúa como en `subscribe` (y se suscribe si aún no lo estaba), como máximo **3 lugares** por conexión. La respuesta es `presence.joined` (`room`, `editing`) seguido de una instantánea `presence`, o `presence.denied` (`room`), sin motivo.
- `presence.leave` al salir. No hay heartbeat: el ping del servidor mantiene el lugar, que desaparece 45 segundos después de la última respuesta; cerrar la conexión lo quita al instante.
- `presence.active` cuando hay interacción (tecla, clic, foco), como máximo cada 10 segundos. Entrar cuenta como actividad.

En cada cambio (como máximo 1 por segundo por sala), la sala recibe una instantánea montada para cada conexión:

```json
{
  "type": "presence", "room": "area:campaigns", "area": "area:campaigns",
  "members": [{ "memberId": "…", "name": "Maria", "avatarUrl": "…", "editing": false, "state": "active", "lastActiveAt": "…" }],
  "count": 3, "editingCount": 1, "at": "2026-10-28T14:05:00.000Z"
}
```

- Tú **nunca** estás en tu propia lista. `members` trae como máximo 5 personas (editando y activas primero, luego las activas, luego las inactivas); `count` es el total de las otras personas en la sala.
- **Quien solo lee:** en las salas de espacio y de elemento, quien no está editando ahí recibe solo `memberId`, `name`, `avatarUrl` y `state`, sin `editing`, `lastActiveAt` ni `editingCount`, para preservar a quien edita. Quien edita el mismo elemento y las salas de área del equipo reciben la instantánea completa.
- `state: "idle"`: conectada, sin actividad desde hace 30 segundos o más. Sigue en la lista.
- **Privacidad:** quien desactivó **Disponible para conversar** no aparece ni cuenta para los demás, pero ve quién está. Un bloqueo en el chat oculta a las dos personas entre sí.
- **Chat de la comunidad desactivado:** las salas de área y quien solo lee no reciben a nadie (`members` vacío, `count` 0), incluido el equipo. Solo quien edita el mismo elemento sigue viendo a los otros editores; `item.saved` y el `409` de versión no cambian.

### Edición e `item.saved`

En un editor (evento, campaña, e-mail de la biblioteca, workflow, curso, formulario, encuesta, plantilla de certificado, e-mail predeterminado y la configuración, en `area:settings`), entra con `"editing": true`. Solo vale para quien puede guardar ahí (el equipo y, en el formulario, quien lo gestiona). Quien edita aparece con `editing: true` para los **otros editores** de la sala, incluso con el estado del chat desactivado; quien solo lee la página no se entera. `editingCount` cuenta solo a quien edita activo.

En cada guardado, la sala recibe, para el equipo y para quien está editando:

```json
{ "type": "item.saved", "topic": "item.saved", "room": "item:campaign:<id>", "itemType": "campaign", "itemId": "<id>", "by": { "memberId": "…", "name": "Maria" }, "updatedAt": "<nueva versión>", "at": "…" }
```

Compara `updatedAt` con la versión que tienes para ignorar tu propio guardado. `itemType`: `event`, `campaign`, `email`, `workflow`, `course` (incluidos módulos y lecciones), `form`, `survey`, `certificateTemplate`, `emailOverride` (`itemId` = `emailKey`) y, en la sala `area:settings` (`itemId` = la comunidad), `settings`, `chatSettings`, `notificationSettings`, `messagingSettings` (SMS + WhatsApp), `creditSettings`, `businessInformation`, `payoutSettings`.

Los guardados aceptan la versión leída y responden `409` cuando otro llegó antes: ver [Conflicto de versión](/api/respostas-e-erros#conflicto-de-version).

## Otros eventos

Del servidor, en JSON `{ type, data }`: `ready` (con `profileId`, `communityId`, `chatAvailable`, `chatStatus` (el estado elegido en el chat), `chatEnabled`, `rooms`, `maxRooms` y `pingIntervalMs`), `notification.new`, `notification.read` y `notification.removed` (la campana, en toda comunidad), `chat.settings`, `session.revoked` y `pong`. Los eventos del chat solo llegan con el chat **activado**; con el chat desactivado, el socket sigue en pie y solo esos se detienen.

Del cliente: `subscribe`, `unsubscribe`, `presence.join`, `presence.leave`, `presence.active`, `presence.idle` (`{ "type": "presence.idle", "idle": true }` cuando la pestaña lleva 10 minutos inactiva, `false` cuando vuelve: la persona aparece **Ausente** mientras todas sus pestañas están inactivas), `typing` (solo con el chat activado) y `ping`.

## Límites y códigos de cierre

- Mensajes de hasta **4 KB**, sin binario, como máximo **5 por segundo**.
- El servidor envía `ping` cada 25 segundos.
- Para reconectar, espera un tiempo aleatorio y creciente, y pide un **ticket nuevo**.

| Código | Qué significa | Qué hacer |
|---|---|---|
| `4004` | Sesión terminada | No reconectes con la misma sesión |
| `4009` | Reemplazada: la persona abrió más de 10 conexiones y se cerró la más antigua | Nada, si es la pestaña antigua |
| `4029` | Servidor lleno | Conéctate más tarde y usa las rutas mientras tanto |
| `1008` | Demasiados mensajes | Baja el ritmo |
| `1001` | Servidor reiniciándose | Reconecta |

## Relacionados

- [Autenticación](/api/autenticacao)
- [X-CommunityId](/api/x-community-id)
- [Mensajes](/membros/mensagens)
