# Tempo real (WebSocket)

> O WebSocket da plataforma - o ticket de uso único, o subprotocolo memberfy-realtime, as salas automáticas e as pedidas, os avisos invalidate, os limites e os códigos de fecho.

A app recebe as mudanças de imediato por um WebSocket: o sino, o chat, o "Online agora", quem está em cada ecrã, a faixa de evento ao vivo, os números das campanhas. Qualquer membro da comunidade, de qualquer função, abre o socket, **com o chat ligado ou desligado**.

## Abrir a ligação

1. Peça um ticket com `POST /api/realtime/ticket`, com o token e o `X-CommunityId` (ver [Autenticação](/api/autenticacao) e [X-CommunityId](/api/x-community-id)). O ticket é de **uso único**, vale **60 segundos** e fica preso à pessoa, à comunidade e à sessão.
2. Abra `wss://<api>/api/realtime/socket` (o `data.path`) passando o ticket como **segundo subprotocolo**, depois de `memberfy-realtime` (o `data.protocols`). O servidor responde com `memberfy-realtime`.

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

O ticket nunca vai no URL (na query, é ignorado). Sem ticket válido, a ligação é recusada antes de se tornar WebSocket (401). Mudar de comunidade fecha o socket: peça outro ticket. Sem o serviço de tempo real, o ticket devolve `503`, e a app continua a ir buscar pelas rotas.

`POST /api/chat/ticket` e `/api/chat/socket` continuam a funcionar como **outros nomes** destes durante um deploy e depois saem: use os novos.

## Salas

Toda a ligação entra logo em três salas:

| Sala | Quem |
|---|---|
| `me` | A própria pessoa |
| `community` | Todos da comunidade |
| `staff` | Só a equipa (proprietário, administrador, moderador e financeiro) |

As outras são pedidas pelo cliente, com a mesma regra do `GET` correspondente, sempre nesta comunidade:

| Sala | Quem entra |
|---|---|
| `space:<id>` | Quem abre o espaço |
| `item:event:<id>` | Quem abre o evento |
| `item:post:<id>` | Quem abre a publicação |

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

Há também salas para os editores, só para as funções que podem gravar ali: `item:course:<id>`, `item:form:<id>` (a equipa e quem gere o formulário; um membro quando está publicado e abre o espaço), `item:campaign:<id>`, `item:email:<id>` (e-mail da biblioteca), `item:workflow:<id>`, `item:survey:<id>` (proprietário e administrador), `item:certificateTemplate:<id>` (quem emite certificados no espaço), `item:emailOverride:<emailKey>` (e-mail predefinido, proprietário e administrador). E as salas de área, uma por ecrã de secção: `area:home` (todos), `area:members`, `area:campaigns`, `area:emails`, `area:workflows` (equipa), `area:moderation` (proprietário, administrador e moderador), `area:analytics`, `area:dashboard`, `area:settings` (proprietário e administrador) e `area:finance` (proprietário, administrador e financeiro).

No máximo **20 salas** por ligação (as automáticas não contam). A resposta é `subscribed` com as salas aceites e um `subscribe.denied` por sala recusada, **sem motivo**. Quando o acesso da pessoa muda (saiu, nova função, grupo, acesso do espaço), o servidor reavalia as salas, manda `subscribe.denied` das que perdeu e depois `access.changed`.

## Avisos de mudança (`invalidate`)

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

O aviso traz **só os ids e o tópico**, nunca título, texto, valor ou nome: ao recebê-lo, refaça o `GET` do que está no ecrã pelas rotas de sempre.

| Tópico | Sala | Quando |
|---|---|---|
| `event.live` | `community`, `space:<id>`, `item:event:<id>` | Um evento começou ou terminou a transmissão |
| `events` | `community`, `space:<id>`, `item:event:<id>` | Um evento foi criado, editado ou removido |
| `analytics.online` | `staff` (proprietário e administrador) | O "Online agora" mudou (no máximo 1 a cada 5 segundos) |
| `campaigns.stats` | `staff` | Um envio de campanha terminou |
| `menu.changed` | `community` | O menu mudou: secção ou espaço criado, editado (nome, ícone, endereço, secção, acesso, visibilidade), removido ou reordenado. Sem `ids` e sem nenhum dado do menu: cada pessoa refaz o `GET /api/sections` com as próprias permissões. Mudanças seguidas resultam num só aviso, em até 1 segundo |

Os avisos de evento chegam só a quem vê esse espaço; evento privado ou em moderação, só à equipa.

## Quem está aqui

A presença de área (os rostos no topo de cada ecrã, ver [Quem está aqui e edição em equipa](/conceitos/presenca-e-edicao-em-equipe)) usa as mesmas salas.

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

- `presence.join` ao abrir o ecrã: a sala é avaliada como no `subscribe` (e subscrita, se ainda não estava), no máximo **3 lugares** por ligação. A resposta é `presence.joined` (`room`, `editing`) seguido de um retrato `presence`, ou `presence.denied` (`room`), sem motivo.
- `presence.leave` ao sair. Não há heartbeat: o ping do servidor mantém o lugar, que desaparece 45 segundos depois da última resposta; fechar a ligação tira-o de imediato.
- `presence.active` quando há interação (tecla, clique, foco), no máximo a cada 10 segundos. Entrar conta como atividade.

A cada mudança (no máximo 1 por segundo por sala), a sala recebe um retrato montado para cada ligação:

```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"
}
```

- **Nunca** está na sua própria lista. `members` traz no máximo 5 pessoas (a editar e ativas primeiro, depois as ativas, depois as inativas); `count` é o total das outras pessoas na sala.
- **Quem só lê:** nas salas de espaço e de item, quem não está a editar ali recebe só `memberId`, `name`, `avatarUrl` e `state`, sem `editing`, `lastActiveAt` nem `editingCount`, para preservar quem edita. Quem edita o mesmo item e as salas de área da equipa recebem o retrato completo.
- `state: "idle"`: ligada, sem atividade há 30 segundos ou mais. Continua na lista.
- **Privacidade:** quem desligou **Disponível para conversar** não aparece nem conta para os outros, mas vê quem está. Um bloqueio no chat esconde as duas pessoas uma da outra.
- **Chat da comunidade desligado:** as salas de área e quem só lê não recebem ninguém (`members` vazio, `count` 0), incluindo a equipa. Só quem edita o mesmo item continua a ver os outros editores; o `item.saved` e o `409` de versão não mudam.

### Edição e `item.saved`

Num editor (evento, campanha, e-mail da biblioteca, workflow, curso, formulário, inquérito, modelo de certificado, e-mail predefinido e as definições, em `area:settings`), entre com `"editing": true`. Só vale para quem pode gravar ali (a equipa e, no formulário, quem o gere). Quem edita aparece com `editing: true` aos **outros editores** da sala, mesmo com o estado do chat desligado; quem só lê a página não fica a saber. `editingCount` conta só quem edita ativo.

A cada gravação, a sala recebe, para a equipa e para quem está a editar:

```json
{ "type": "item.saved", "topic": "item.saved", "room": "item:campaign:<id>", "itemType": "campaign", "itemId": "<id>", "by": { "memberId": "…", "name": "Maria" }, "updatedAt": "<nova versão>", "at": "…" }
```

Compare `updatedAt` com a versão que tem para ignorar a sua própria gravação. `itemType`: `event`, `campaign`, `email`, `workflow`, `course` (incluindo módulos e aulas), `form`, `survey`, `certificateTemplate`, `emailOverride` (`itemId` = `emailKey`) e, na sala `area:settings` (`itemId` = a comunidade), `settings`, `chatSettings`, `notificationSettings`, `messagingSettings` (SMS + WhatsApp), `creditSettings`, `businessInformation`, `payoutSettings`.

As gravações aceitam a versão lida e respondem `409` quando outra veio antes: ver [Conflito de versão](/api/respostas-e-erros#conflito-de-versao).

## Outros eventos

Do servidor, em JSON `{ type, data }`: `ready` (com `profileId`, `communityId`, `chatAvailable`, `chatStatus` (o estado escolhido no chat), `chatEnabled`, `rooms`, `maxRooms` e `pingIntervalMs`), `notification.new`, `notification.read` e `notification.removed` (o sino, em todas as comunidades), `chat.settings`, `session.revoked` e `pong`. Os eventos do chat só chegam com o chat **ligado**; com o chat desligado, o socket continua de pé e só esses param.

Do cliente: `subscribe`, `unsubscribe`, `presence.join`, `presence.leave`, `presence.active`, `presence.idle` (`{ "type": "presence.idle", "idle": true }` quando o separador fica 10 minutos parado, `false` quando volta: a pessoa aparece **Ausente** quando todos os separadores dela estão parados), `typing` (só com o chat ligado) e `ping`.

## Limites e códigos de fecho

- Quadros até **4 KB**, sem binário, no máximo **5 por segundo**.
- O servidor manda `ping` a cada 25 segundos.
- Para voltar a ligar, espere um tempo aleatório e crescente e peça um **ticket novo**.

| Código | O que significa | O que fazer |
|---|---|---|
| `4004` | Sessão terminada | Não volte a ligar com a mesma sessão |
| `4009` | Substituída: a pessoa abriu mais de 10 ligações, e a mais antiga fechou | Nada, se for o separador antigo |
| `4029` | Servidor cheio | Volte a ligar mais tarde e use as rotas entretanto |
| `1008` | Quadros a mais | Abrande o ritmo |
| `1001` | Servidor a reiniciar | Volte a ligar |

## Relacionados

- [Autenticação](/api/autenticacao)
- [X-CommunityId](/api/x-community-id)
- [Mensagens](/membros/mensagens)
