Pular para o conteúdo principal

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órioTecnologiaPapel
CentralCRM_backNestJS + TypeORM + Socket.IOAPI central + gateway de presença em tempo real
CentralCRM_frontReact 18 + Vite + socket.io-clientPainel admin que visualiza quem está online
default-crmNestJS + PassportJSBackend do CRM dos clientes finais
default_crm-frontNext.js 16 + React 19Frontend 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 nullNotFoundException.

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:

  1. SessionPayload agora inclui centralPresenca
  2. persistEncryptedSession guarda centralPresenca no sessionStorage encriptado
  3. getStoredSession restaura centralPresenca ao 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çoJWT_SECRET
CentralCRM_backcbd69e042b6a753bbea40296711f2b2c4cc3e7ad9d73e1c7dcb0623edaa3876e9e634e882565081d0a9c683fd10300d18
default-crmcbd69e... (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

FicheiroTipo de Alteração
CentralCRM_back/src/modules/presence/application/online-company-presence.service.tsFix admin tenant + fix hierarquia sub-empresas
CentralCRM_back/src/modules/presence/gateways/presence.gateway.tsAdmin como observador, notifica admin:observer
CentralCRM_back/src/modules/presence/application/user-presence.service.tsAdicionado getAllActiveSessions()
CentralCRM_front/src/presence/connectPresenceSocket.tsLogs de diagnóstico
CentralCRM_front/src/presence/PresenceContext.tsxLogs + connect_error handler
CentralCRM_front/src/common/DashboardLayout.tsxRemovido filtro de tenantId
CentralCRM_front/src/directories/views/DirectoriesView.tsxPresença em tempo real via socket
default-crm/src/modules/auth/auth.service.tsFallback hardcoded para CENTRAL_CRM_ORIGIN
default-crm/src/modules/auth/auth.module.tsFallback hardcoded para JWT_SECRET
default-crm/.envJWT_SECRET + CENTRAL_CRM_* vars
default_crm-front/src/models/types/auth.tsInterface CentralPresencaConfig
default_crm-front/src/models/services/auth.service.tsPersistência/restauro de centralPresenca
default_crm-front/src/presence/useCentralPresence.tsNOVO — hook do socket
default_crm-front/src/views/layouts/DashboardLayout.tsxMonta useCentralPresence

11. PENDÊNCIAS (para produção completa)

  1. Remover hardcodes de auth.service.ts e auth.module.ts após configurar .env no servidor
  2. CORS: CORS_ORIGIN no CentralCRM_back deve incluir o origin do default_crm-front em produção
  3. Verificar company_id: se user.company_id e user.company_uuid forem NULL na BD para um utilizador, ele aparece em onlineUserCountUnassignedInNetwork mas não em onlineUserCount por empresa
  4. Logs de diagnóstico no connectPresenceSocket.ts e PresenceContext.tsx podem ser removidos (ou colocados atrás de import.meta.env.DEV) para produção