Appearance
Dispositivos conectados do app
Gestão dos aparelhos mobile conectados a uma conta. Uma conta pode manter vários aparelhos conectados ao mesmo tempo, sem limite. Cada aparelho é uma linha em app_device_sessions, identificada por um deviceId estável gerado e guardado pelo app.
Endpoints
| Endpoint | Método | Auth | Descrição |
|---|---|---|---|
/app/devices | GET | JWT + x-company-id | Lista os aparelhos ativos (não revogados e com sinal de vida recente) do usuário autenticado |
/app/devices | DELETE | JWT + x-company-id | Revoga todos os aparelhos do usuário de uma vez |
/app/devices/:id | DELETE | JWT + x-company-id | Revoga um aparelho específico (desconecta de verdade) |
/app/heartbeat | POST | JWT | Sinal de vida do app; atualiza lastSeenAt para manter o aparelho ativo na lista |
/app/exchange-token | POST | pública | Troca QR/master key por sessão; agora aceita deviceId opcional |
/app/mobile-logout | POST | JWT + x-company-id | Logout do próprio app; aceita deviceId opcional para revogar a sessão |
GET /app/devices
Retorna a lista de aparelhos ativos do usuário, ordenados pelo último acesso. "Ativo" = sem revokedAt e com lastSeenAt mais recente que o limite de inatividade (APP_SESSION_STALE_THRESHOLD_MS, 5 min). Aparelhos sem sinal de vida há mais que esse limite (app encerrado, sem rede, travado) saem da lista mesmo sem terem sido revogados, e voltam quando o app reaparece e envia heartbeat.
typescript
type AppDeviceResponse = {
id: string;
deviceId: string;
deviceName: string | null;
brand: string | null;
modelName: string | null;
osName: string | null;
osVersion: string | null;
connectedAt: string;
lastSeenAt: string;
};json
{
"data": [
{
"id": "9f1c0e2a-0000-4000-8000-000000000001",
"deviceId": "b2d1a7c4-0000-4000-8000-000000000abc",
"deviceName": "iPhone de João Exemplo",
"brand": "Apple",
"modelName": "iPhone 14",
"osName": "ios",
"osVersion": "17.2",
"connectedAt": "2026-06-24T12:00:00.000Z",
"lastSeenAt": "2026-06-24T13:10:00.000Z"
}
]
}DELETE /app/devices
Revoga todas as sessões ativas do usuário de uma vez (revokedAt = agora em updateMany). Responde 204 No Content. Emite um único app:disconnected (com deviceId: null e source: "dashboard") para o painel limpar a lista e para todos os apps do usuário deslogarem na próxima ação. É o que sustenta o botão discreto "Desconectar todos" do painel.
DELETE /app/devices/:id
Revoga a sessão do aparelho (revokedAt = agora). Responde 204 No Content. Emite o evento de socket app:disconnected (com o deviceId) para o painel atualizar a lista e para o aparelho alvo deslogar na hora, se estiver online. Se :id não pertencer ao usuário (ou já estiver revogado), responde 404.
POST /app/heartbeat
Body: { deviceId }. Atualiza o lastSeenAt da sessão ativa de (userId, deviceId) para "agora" (updateMany sobre sessões não revogadas). Responde 204 No Content. Não emite socket nem mexe em revokedAt: é apenas um sinal de vida barato. O app envia a cada ~1 min enquanto está em primeiro plano e ao voltar do background. Usa @SkipCompany (não exige x-company-id), pois é liveness e independe do contexto de empresa. Sessão revogada ou inexistente: no-op silencioso (204).
POST /app/exchange-token
Body: { token, deviceId?, deviceInfo? }. Quando deviceId é enviado, o backend faz upsert de uma linha em app_device_sessions por (userId, deviceId) e devolve um accessToken (JWT) que carrega o claim sid (id da sessão de dispositivo). Sem deviceId (ex.: master key de QA), o token é emitido sem sid e segue válido como antes.
POST /app/mobile-logout
Body: { deviceId? }. Com deviceId, revoga a sessão daquele aparelho e emite app:disconnected. Sem deviceId, apenas emite o evento (compatibilidade com versões antigas do app).
Revogação real (claim sid)
O JwtAuthGuard/JwtStrategy valida o claim sid quando presente: se a sessão de dispositivo correspondente não existe ou está revogada, a requisição é rejeitada com 401 "Dispositivo desconectado". É isso que torna a desconexão real — o token do aparelho revogado para de funcionar na próxima requisição, mesmo que o app estivesse offline no momento da revogação. Tokens web (sem sid) não são afetados.
Tabela app_device_sessions
| Coluna | Tipo | Nullable | Notas |
|---|---|---|---|
id | UUID | não | PK; vira o claim sid do token do app |
userId | UUID | não | FK → users (cascade) |
companyId | UUID | sim | Empresa do contexto na hora do pareamento |
deviceId | text | não | Identificador estável gerado pelo app |
deviceName | text | sim | Ex.: "iPhone de João Exemplo" |
brand | text | sim | Ex.: "Apple" |
modelName | text | sim | Ex.: "iPhone 14" |
osName | text | sim | ios | android |
osVersion | text | sim | Ex.: "17.2" |
lastSeenAt | timestamptz | não | Sinal de vida; atualizado no pareamento e a cada heartbeat do app. Sessão sem heartbeat há > 5 min sai da listagem |
revokedAt | timestamptz | sim | Preenchido na desconexão; null = ativo |
createdAt | timestamptz | não | |
updatedAt | timestamptz | não |
Único: (userId, deviceId). Índice: (userId).
Erros conhecidos
401 "Dispositivo desconectado"— token comsidcuja sessão foi revogada (ou não existe). O app trata como logout.404no DELETE —:idinexistente ou de outro usuário.401 "Token invalido ou ja utilizado"— QR token expirado/reusado no exchange (inalterado).
Regras de negócio
As regras de produto (sem limite de aparelhos, desconexão por aparelho, revogação real) estão em business/app-qr-login.md.