ENTENDIMENTO — Documentação Técnica Completa do Sistema de Presença
Data: 23 de Abril de 2026
Escopo: CentralCRM_back · CentralCRM_front · default-crm · default_crm-front
Cópia de navegação na raiz de
docs/. Canónico no site: arquitetura/entendimento.md.
Arquitetura geral: arquitetura/visao-completa.md. Runbook: runbooks/presenca.md.
1. VISÃO GERAL DO SISTEMA
O sistema é composto por quatro repositórios que trabalham em conjunto:
┌─────────────────────────────────────────────────────────────────────┐
│ ARQUITETURA GERAL │
│ │
│ default_crm-front ──────────────────────────────────────────────┐ │
│ (Next.js 16 / React 19) │ │
│ │ login │ │
│ ▼ │ │
│ default-crm (NestJS) ─── JWT signed with cbd69e... ─────────────┤ │
│ │ sessionHash + centralPresenca config │ │
│ ▼ │ │
│ default_crm-front abre WebSocket ──────────────────────────────┐ │ │
│ │ │ │
│ Socket.IO /presence │ │ │
│ │ │ │ │
│ ▼ │ │ │
│ CentralCRM_back (NestJS) │ │ │
│ porta 3000 / https://centralcrmapixyz.gevtech.com.br│ │ │
│ PresenceGateway → UserPresenceService (Map memória) │ │ │
│ │ │ │ │
│ │ REST API │ │ │
│ ▼ │ │ │
│ CentralCRM_front (React/Vite) ◄─────────────────────┘ │ │
│ https://centralcrmapixyz.gevtech.com.br (front) │ │
│ Painel admin visualiza utilizadores online ◄──────────┘ │
└─────────────────────────────────────────────────────────────────────┘
Papéis de cada repositório
| Repositório | Tecnologia | Papel |
|---|---|---|
CentralCRM_back | NestJS + TypeORM + Socket.IO | API central + gateway de presença em tempo real |
CentralCRM_front | React 18 + Vite + socket.io-client | Painel admin que visualiza quem está online |
default-crm | NestJS + PassportJS | Backend do CRM dos clientes finais |
default_crm-front | Next.js 16 + React 19 | Frontend do CRM dos clientes finais |
2. O PROBLEMA ORIGINAL (PONTO DE PARTIDA)
Sintoma relatado
GET /api/v1/presence/online-count/aggregated-by-parent?parentCompanyId=348e799b-... retornava 404.
O utilizador teste.validacao@exemplo.local estava activo no default-crm mas a API de presença mostrava onlineUserCount: 0.
Causa raiz (3 problemas em cadeia)
PROBLEMA 1: JWT_SECRET diferente
default-crm assinava tokens com: 8430a10b...
CentralCRM_back verificava com: cbd69e...
→ Gateway rejeitava qualquer token do default-crm com INVALID_TOKEN
PROBLEMA 2: CENTRAL_CRM_ORIGIN não configurado
default-crm não sabia para onde mandar os utilizadores ligar
→ Login devolvia: centralPresenca.enabled = false
→ default_crm-front nunca abria socket
PROBLEMA 3: default_crm-front sem infraestrutura de socket
Não tinha socket.io-client instalado
Não tinha tipo CentralPresencaConfig
Não tinha hook para abrir socket
Não tinha código para persistir/restaurar config de presença
PROBLEMA 4: Admin do CentralCRM rejeitado pelo gateway
JWT do admin (demo@centralcrm.local) não tem tenantId
Gateway exigia tenantId → emitia TENANT_REQUIRED → desconectava
Frontend bloqueava token → PresenceProvider recebia null → sem socket
PROBLEMA 5: aggregated-by-parent retornava 404 para sub-empresas
UUID 6fd9b091-... é uma sub-empresa de 348e799b-...
Backend rejeitava com "A empresa indicada é uma sub-empresa"
Frontend devia subir para o root mas passava o UUID errado
3. ALTERAÇÕES POR REPOSITÓRIO
3.1 CentralCRM_back
Ficheiro: src/modules/presence/application/online-company-presence.service.ts
Problema: O método resolveRequestTenantId verificava primeiro o header x-tenant-id e só depois verificava se o utilizador era admin. O admin tem o seu próprio tenantId (8b9c00b9-...) que não corresponde ao tenantId da empresa alvo (348e799b-... pertence a outro tenant). Resultado: requireCompanyForTenant(wrongTenant, companyId) retornava null → NotFoundException.
Solução: Reordenar a lógica — admin verifica PRIMEIRO, resolve o tenant directamente da base de dados pela empresa.
// ANTES (bugado):
private async resolveRequestTenantId(requestTenantId, companyId, user) {
const t = requestTenantId?.trim() ?? '';
if (t) return t; // ← retornava tenant ERRADO do admin
if (user.role === USER_ROLE_ADMIN) {
// nunca chegava aqui
}
}
// DEPOIS (correcto):
private async resolveRequestTenantId(requestTenantId, companyId, user) {
if (user.role === USER_ROLE_ADMIN) { // ← verifica admin PRIMEIRO
const c = await this.companyOrm.findOne({ where: { id: companyId } });
if (!c) throw new NotFoundException('Company não encontrada...');
return c.tenantId; // resolve tenant real da empresa alvo
}
const t = requestTenantId?.trim() ?? '';
if (t) return t;
throw new UnauthorizedException('...');
}
Segunda alteração no mesmo ficheiro — sub-empresas em cascata:
O endpoint aggregated-by-parent rejeitava qualquer UUID de sub-empresa com erro, mesmo que essa sub-empresa tivesse sub-empresas próprias (hierarquia multi-nível). O frontend passava 6fd9b091-... (sub-empresa) em vez de 348e799b-... (root).
// ANTES:
const parent = await this.requireCompanyForTenant(resolvedTenant, parentCompanyId);
if (readParentCompanyId(parent.metadata) != null) {
throw new NotFoundException('A empresa indicada é uma sub-empresa...');
}
// DEPOIS — sobe automaticamente até ao root:
let parent = await this.requireCompanyForTenant(resolvedTenant, parentCompanyId);
const MAX_DEPTH = 10;
let depth = 0;
while (readParentCompanyId(parent.metadata) != null && depth < MAX_DEPTH) {
const upperParentId = readParentCompanyId(parent.metadata)!;
const upper = await this.companyOrm.findOne({ where: { id: upperParentId } });
if (!upper) break;
parent = upper;
depth++;
}
if (readParentCompanyId(parent.metadata) != null) {
throw new NotFoundException('A empresa indicada é uma sub-empresa...');
}
Ficheiro: src/modules/presence/gateways/presence.gateway.ts
Problema: O gateway exigia tenantId em todos os tokens. O admin do CentralCRM tem JWT com role:admin mas sem tenantId. Resultado: gateway emitia TENANT_REQUIRED e desconectava.
Solução: Detectar role === 'admin' antes de verificar tenantId. Admin entra numa sala especial admin:observer, não é registado como utilizador online, mas recebe todos os eventos em tempo real.
// Lógica adicionada após verificar userId:
const role = (payload as Record<string, unknown>)['role'] as string | undefined;
if (role === 'admin') {
await client.join('admin:observer');
const allUsers = this.presence.getAllActiveSessions();
client.emit('presence:sync', { users: allUsers });
return; // não regista como utilizador online
}
// continua lógica normal para utilizadores do default-crm...
Também adicionado: quando um utilizador do default-crm liga/desliga, o servidor também notifica a sala admin:observer:
// No handleDisconnect:
this.server.to('admin:observer').emit('presence:leave', { userId });
// No handleConnection (após registar sessão):
this.server.to('admin:observer').emit('presence:join', {
userId, email, nome, status: 'online', tenantId, companyId
});
Ficheiro: src/modules/presence/application/user-presence.service.ts
Adicionado: método getAllActiveSessions() para devolver todos os utilizadores activos de todos os tenants — necessário para o presence:sync inicial enviado aos admins observadores.
getAllActiveSessions(): ActiveUserSummary[] {
const tenantIds = new Set(
[...this.bySocketId.values()].map((s) => s.tenantId),
);
return [...tenantIds].flatMap((tid) => this.getActiveUsersForTenant(tid));
}
3.2 CentralCRM_front
Ficheiro: src/presence/connectPresenceSocket.ts
Problema: Sem logs, impossível diagnosticar falhas de ligação.
Solução: Adicionados logs detalhados a todos os eventos do engine Socket.IO:
// Logs adicionados:
console.log('[CentralPresence] connectPresenceSocket', { origin, namespace, tokenPreview });
s.io.on('open', () => console.log('engine open'));
s.io.on('close', (reason) => console.warn('engine close', reason));
s.io.on('error', (err) => console.error('engine error', err));
s.io.on('reconnect_attempt', (n) => console.log(`reconnect attempt #${n}`));
s.io.on('reconnect_failed', () => console.error('reconnect FAILED'));
Ficheiro: src/presence/PresenceContext.tsx
Problema: Sem logs, TENANT_REQUIRED era silencioso. Também não havia connect_error handler.
Solução: Logs em todos os eventos + handler connect_error explícito:
// Adicionados handlers:
const onConnect = () => console.log('✅ connected socketId=' + s.id);
const onDisconnect = (reason) => console.warn('❌ disconnected', reason);
const onConnectError = (err) => console.error('connect_error', err.message, err);
const onPresenceError = (payload) => console.error('presence:error', payload);
const onSync = (payload) => console.log('presence:sync', payload);
const onJoin = (payload) => console.log('presence:join', payload);
const onLeave = (payload) => console.log('presence:leave', payload);
// connect_error é novo — antes não era escutado:
s.on('connect_error', onConnectError);
Ficheiro: src/common/DashboardLayout.tsx
Problema: O filtro extractTenantIdFromAccessToken(token) bloqueava o token do admin (sem tenantId no JWT) → PresenceProvider recebia null → socket nunca abria.
// ANTES (bloqueava admin):
const presenceToken = session?.token && extractTenantIdFromAccessToken(session.token)
? session.token
: null;
// DEPOIS (passa sempre — gateway trata de diferenciar):
const presenceToken = session?.token ?? null;
Ficheiro: src/directories/views/DirectoriesView.tsx
Problema: A presença era carregada UMA VEZ via REST quando a lista de empresas mudava. Quando um utilizador ligava/desligava, o número não actualizava — era preciso refrescar a página.
Solução: Escutar eventos do socket e incrementar um contador (presenceTick) que força o useEffect de presença a re-executar:
// Novo import:
import { usePresence } from '../../presence';
// Novo estado:
const [presenceTick, setPresenceTick] = useState(0);
// Novo useEffect — escuta socket:
const { socket } = usePresence();
useEffect(() => {
if (!socket) return;
const bump = () => setPresenceTick((n) => n + 1);
socket.on('presence:join', bump);
socket.on('presence:leave', bump);
socket.on('presence:status', bump);
socket.on('presence:sync', bump);
socket.on('presence:connection_change', bump);
return () => { /* remove listeners */ };
}, [socket]);
// useEffect de presença agora depende de presenceTick:
}, [companies, presenceTick]); // ← adicionado presenceTick
Fluxo em tempo real resultante:
Utilizador do default-crm liga socket
→ gateway emite presence:join para admin:observer
→ DashboardLayout recebe via PresenceContext
→ bump() → presenceTick++
→ useEffect re-executa → REST API → contagens actualizadas
→ UI actualiza sem refresh
3.3 default-crm (backend)
Ficheiro: src/modules/auth/auth.service.ts
Problema: buildCentralPresencaMetadata() lia CENTRAL_CRM_ORIGIN e CENTRAL_CRM_PRESENCE_ENABLED do .env. Como o servidor remoto não tinha essas variáveis, devolvia sempre enabled: false.
Solução temporária: Hardcodar os valores como fallback enquanto o servidor não é configurado:
private buildCentralPresencaMetadata(): CentralPresencaMetadata {
// TODO: remover fallback após configurar .env no servidor
const HARDCODED_ORIGIN = 'https://centralcrmapixyz.gevtech.com.br';
const rawOrigin = (
this.configService.get<string>('CENTRAL_CRM_ORIGIN') ?? HARDCODED_ORIGIN
).trim();
const flag = (
this.configService.get<string>('CENTRAL_CRM_PRESENCE_ENABLED') ?? 'true'
).trim().toLowerCase();
// ...resto da lógica inalterada...
}
Ficheiro: src/modules/auth/auth.module.ts
Problema: JWT_SECRET tinha fallback 'change-me-in-production'. Se o servidor não tivesse JWT_SECRET no .env, os tokens seriam assinados com esse valor e o gateway do CentralCRM_back rejeitaria com INVALID_TOKEN.
// ANTES:
secret: config.get<string>('JWT_SECRET') || 'change-me-in-production',
// DEPOIS:
secret:
config.get<string>('JWT_SECRET') ||
'cbd69e042b6a753bbea40296711f2b2c4cc3e7ad9d73e1c7dcb0623edaa3876e9e634e882565081d0a9c683fd10300d18',
Ficheiro: .env (local)
# Chave alinhada com CentralCRM_back:
JWT_SECRET=cbd69e042b6a753bbea40296711f2b2c4cc3e7ad9d73e1c7dcb0623edaa3876e9e634e882565081d0a9c683fd10300d18
# Chave antiga mantida para sessões existentes não expirarem:
JWT_EXTERNAL_SECRETS=8430a10b22265292c4f88e9133255a39e22fb008a15f9671ef5237a1af971aaabf80b6df4d78b004c1231dac7c39cf7a95b2f1476d832d7d984f121f873a5495
# Configuração de presença:
CENTRAL_CRM_ORIGIN=https://centralcrmapixyz.gevtech.com.br
CENTRAL_CRM_PRESENCE_ENABLED=true
3.4 default_crm-front (frontend)
Instalação de dependência
npm install socket.io-client --save
Ficheiro: src/models/types/auth.ts
Adicionado: Interface para a configuração de presença devolvida pelo login:
export interface CentralPresencaConfig {
enabled: boolean;
origin: string | null;
namespace: string; // '/presence'
socketIoPath: string; // '/socket.io'
restPresenceBaseUrl: string | null;
useSessionToken: true;
}
// Adicionado ao LoginResponse:
centralPresenca?: CentralPresencaConfig | null;
Ficheiro: src/models/services/auth.service.ts
Alterações:
SessionPayloadagora incluicentralPresencapersistEncryptedSessionguardacentralPresencanosessionStorageencriptadogetStoredSessionrestauracentralPresencaao desencriptar
// SessionPayload ampliado:
export type SessionPayload = {
sessionHash: string;
userData: { ... };
centralPresenca?: CentralPresencaConfig | null;
// ...
};
// Ao persistir sessão:
const toStore = {
sessionHash: data.sessionHash,
centralPresenca: data.centralPresenca?.enabled && data.centralPresenca.origin
? data.centralPresenca
: null,
// ...
};
Ficheiro: src/presence/useCentralPresence.ts (ficheiro novo)
Hook que abre a ligação WebSocket ao CentralCRM_back após login. Usa dynamic import para evitar erros de SSR no Next.js.
export function useCentralPresence(session: SessionPayload | null | undefined): void {
useEffect(() => {
const config = session?.centralPresenca;
const token = session?.sessionHash?.trim();
// Só liga se presença estiver activa e token disponível:
if (!config?.enabled || !config.origin || !token) return;
let socket: Socket | null = null;
(async () => {
const { io } = await import('socket.io-client'); // dynamic import (SSR-safe)
socket = io(`${config.origin}${config.namespace}`, {
path: config.socketIoPath,
transports: ['websocket', 'polling'],
auth: { token },
reconnection: true,
});
// Logs de diagnóstico:
socket.on('connect', () => console.log('[CentralPresence] ✅ connected'));
socket.on('disconnect', (r) => console.warn('[CentralPresence] ❌ disconnected', r));
socket.on('presence:error', (p) => console.error('[CentralPresence] error', p));
})();
return () => {
socket?.removeAllListeners();
socket?.disconnect();
};
}, [session?.sessionHash, session?.centralPresenca?.origin, session?.centralPresenca?.enabled]);
}
Ficheiro: src/views/layouts/DashboardLayout.tsx
Adicionado: Carrega sessão do sessionStorage e chama useCentralPresence:
import { useCentralPresence } from '@/presence/useCentralPresence';
// No componente:
const [presenceSession, setPresenceSession] = useState<SessionPayload | null>(null);
useEffect(() => {
let cancelled = false;
getStoredSession().then((s) => {
if (!cancelled) setPresenceSession(s);
}).catch(() => null);
return () => { cancelled = true; };
}, []);
useCentralPresence(presenceSession);
4. FLUXO COMPLETO END-TO-END
1. Utilizador abre default_crm-front e faz login
POST https://defaultcrmapi.gevtech.com.br/api/auth/login
{
tenantSlug: "11",
email: "teste.validacao@exemplo.local",
password: "Pass@123"
}
2. default-crm (auth.service.ts) assina JWT com cbd69e... e retorna:
{
sessionHash: "eyJ..." (signed with cbd69e...),
centralPresenca: {
enabled: true,
origin: "https://centralcrmapixyz.gevtech.com.br",
namespace: "/presence",
socketIoPath: "/socket.io",
...
}
}
3. default_crm-front persiste sessão encriptada no sessionStorage
(inclui centralPresenca e sessionHash)
4. DashboardLayout carrega sessão → chama useCentralPresence()
5. useCentralPresence abre socket:
io("https://centralcrmapixyz.gevtech.com.br/presence", {
path: "/socket.io",
auth: { token: sessionHash }
})
6. CentralCRM_back / PresenceGateway recebe conexão:
- Verifica JWT com cbd69e... → válido ✅
- Lê sub (userId) e tenantId do payload
- role !== 'admin' → fluxo normal
- Busca CompanyUserEntity WHERE id = userId
- Verifica tenantId coincide
- Regista sessão em UserPresenceService (Map em memória):
{ socketId, userId, tenantId, companyId, email, nome, status:'online' }
- Entra na sala tenant:<tenantId>
- Emite presence:sync para o utilizador
- Emite presence:join para a sala tenant + admin:observer
7. CentralCRM_front / PresenceProvider (admin logado) está na sala admin:observer
- Recebe presence:join
- bump() → presenceTick++
- DirectoriesView re-faz REST:
GET /api/v1/presence/online-count/aggregated-by-parent?parentCompanyId=348e799b-...
- Resposta agora: onlineUserCount: 1 ✅
- UI actualiza em tempo real sem refresh
8. Quando utilizador fecha o browser / logout:
- Socket desliga → PresenceGateway.handleDisconnect()
- Remove da Map
- Emite presence:leave para tenant + admin:observer
- CentralCRM_front re-faz REST → onlineUserCount: 0
5. CONFIGURAÇÃO DO SERVIDOR (docker-compose.yml)
O docker-compose.yml do default-crm no servidor deve ter:
services:
backend:
environment:
- JWT_SECRET=${JWT_SECRET}
- JWT_EXTERNAL_SECRETS=${JWT_EXTERNAL_SECRETS}
- CENTRAL_CRM_ORIGIN=${CENTRAL_CRM_ORIGIN}
- CENTRAL_CRM_PRESENCE_ENABLED=${CENTRAL_CRM_PRESENCE_ENABLED:-false}
# ...resto das variáveis...
E o .env do servidor:
JWT_SECRET=cbd69e042b6a753bbea40296711f2b2c4cc3e7ad9d73e1c7dcb0623edaa3876e9e634e882565081d0a9c683fd10300d18
JWT_EXTERNAL_SECRETS=8430a10b22265292c4f88e9133255a39e22fb008a15f9671ef5237a1af971aaabf80b6df4d78b004c1231dac7c39cf7a95b2f1476d832d7d984f121f873a5495
CENTRAL_CRM_ORIGIN=https://centralcrmapixyz.gevtech.com.br
CENTRAL_CRM_PRESENCE_ENABLED=true
Após alterar .env no servidor:
docker compose up -d --force-recreate backend
6. HIERARQUIA DE EMPRESAS
348e799b-dfdb-465f-a031-e34816203d40 (ROOT / empresa mãe — "1")
└── 6fd9b091-66bd-4f2b-92e7-4d47a8384c5f (sub-empresa — "Segunda empresa (filial)")
O endpoint aggregated-by-parent agora aceita qualquer UUID da hierarquia e sobe automaticamente até ao root (máx. 10 níveis). Antes exigia exactamente o UUID do root.
7. CHAVES JWT — ALINHAMENTO CRÍTICO
| Serviço | JWT_SECRET |
|---|---|
| CentralCRM_back | cbd69e042b6a753bbea40296711f2b2c4cc3e7ad9d73e1c7dcb0623edaa3876e9e634e882565081d0a9c683fd10300d18 |
| default-crm | cbd69e... (mesmo) ← alterado nesta sessão |
| default-crm (chave antiga) | 8430a10b... → movida para JWT_EXTERNAL_SECRETS |
Porquê o alinhamento é obrigatório: O sessionHash gerado pelo default-crm é usado como token para autenticar no gateway do CentralCRM_back. O gateway faz jwtService.verify(token) com a sua própria chave. Se as chaves forem diferentes → INVALID_TOKEN → socket desconecta.
8. SALA ADMIN:OBSERVER — COMO FUNCIONA
Admin (demo@centralcrm.local) abre CentralCRM_front
→ JWT: { sub: "413c0c2c-...", role: "admin" } (SEM tenantId)
→ PresenceProvider envia token ao gateway
Gateway detecta role === 'admin':
→ NÃO verifica tenantId
→ NÃO busca CompanyUserEntity
→ NÃO regista como utilizador online
→ Entra na sala "admin:observer"
→ Recebe presence:sync com todos os utilizadores activos
Quando utilizador do default-crm liga:
→ Gateway regista normalmente em tenant:<tenantId>
→ Também emite para "admin:observer":
presence:join { userId, email, nome, tenantId, companyId }
CentralCRM_front recebe presence:join:
→ presenceTick++ → re-fetch REST → UI actualiza
9. DIAGNÓSTICO — O QUE VER NO CONSOLE
CentralCRM_front (Admin)
[CentralPresence] PresenceProvider effect { hasToken: true, tokenPreview: "eyJ..." }
[CentralPresence] connectPresenceSocket { origin: "https://centralcrmapixyz...", namespace: ".../presence" }
[CentralPresence] engine open
[CentralPresence] ✅ connected socketId=abc123
[CentralPresence] presence:sync { users: [] } ← inicial (sem utilizadores)
[CentralPresence] presence:join { userId: "f000...", tenantId: "aba1...", companyId: "348e..." }
[CentralPresence] presence:leave { userId: "f000..." }
CentralCRM_back (logs do servidor)
[PresenceGateway] admin observer joined: socketId=abc123
[PresenceGateway] user registered: userId=f000..., tenant=aba1..., company=348e...
[PresenceGateway] user disconnected: userId=f000...
10. FICHEIROS ALTERADOS — ÍNDICE RÁPIDO
| Ficheiro | Tipo de Alteração |
|---|---|
CentralCRM_back/src/modules/presence/application/online-company-presence.service.ts | Fix admin tenant + fix hierarquia sub-empresas |
CentralCRM_back/src/modules/presence/gateways/presence.gateway.ts | Admin como observador, notifica admin:observer |
CentralCRM_back/src/modules/presence/application/user-presence.service.ts | Adicionado getAllActiveSessions() |
CentralCRM_front/src/presence/connectPresenceSocket.ts | Logs de diagnóstico |
CentralCRM_front/src/presence/PresenceContext.tsx | Logs + connect_error handler |
CentralCRM_front/src/common/DashboardLayout.tsx | Removido filtro de tenantId |
CentralCRM_front/src/directories/views/DirectoriesView.tsx | Presença em tempo real via socket |
default-crm/src/modules/auth/auth.service.ts | Fallback hardcoded para CENTRAL_CRM_ORIGIN |
default-crm/src/modules/auth/auth.module.ts | Fallback hardcoded para JWT_SECRET |
default-crm/.env | JWT_SECRET + CENTRAL_CRM_* vars |
default_crm-front/src/models/types/auth.ts | Interface CentralPresencaConfig |
default_crm-front/src/models/services/auth.service.ts | Persistência/restauro de centralPresenca |
default_crm-front/src/presence/useCentralPresence.ts | NOVO — hook do socket |
default_crm-front/src/views/layouts/DashboardLayout.tsx | Monta useCentralPresence |
11. PENDÊNCIAS (para produção completa)
- Remover hardcodes de
auth.service.tseauth.module.tsapós configurar.envno servidor - CORS:
CORS_ORIGINno CentralCRM_back deve incluir o origin do default_crm-front em produção - Verificar
company_id: seuser.company_ideuser.company_uuidforem NULL na BD para um utilizador, ele aparece emonlineUserCountUnassignedInNetworkmas não emonlineUserCountpor empresa - Logs de diagnóstico no
connectPresenceSocket.tsePresenceContext.tsxpodem ser removidos (ou colocados atrás deimport.meta.env.DEV) para produção