16 KiB
03 — Fluxos e contratos de API
Contrato normativo. O backend expõe OpenAPI em
/v3/api-docs; os webs geram tipos TS a partir dele e nunca escrevem tipos de API à mão. Prefixos:/api/v1/visitor/**(JWT efêmero de visitante),/api/v1/app/**(morador/operador),/api/v1/admin/**(admin + MFA).
1. Máquina de estados da visita
stateDiagram-v2
[*] --> PENDENTE
PENDENTE --> TOCANDO: dados completos + geofence ok
PENDENTE --> CANCELADA: visitante desiste
TOCANDO --> EM_CHAMADA: morador atende
TOCANDO --> ESCALONADA: 20s sem resposta
TOCANDO --> AUTORIZADA: aprovação de 1 toque (ENTREGA)
TOCANDO --> NEGADA: recusa de 1 toque (ENTREGA)
ESCALONADA --> EM_CHAMADA: outro morador atende
ESCALONADA --> FILA_OPERADOR: 15s + módulo operator_queue ativo
ESCALONADA --> RECADO_EM_VIDEO: 15s sem módulo de operador
ESCALONADA --> EXPIRADA: visitante abandona
FILA_OPERADOR --> EM_CHAMADA: operador assume
FILA_OPERADOR --> RECADO_EM_VIDEO: fila estourou o tempo
FILA_OPERADOR --> EXPIRADA: visitante abandona
EM_CHAMADA --> AUTORIZADA
EM_CHAMADA --> NEGADA
EM_CHAMADA --> EXPIRADA: queda sem decisão
AUTORIZADA --> [*]
NEGADA --> [*]
EXPIRADA --> [*]
RECADO_EM_VIDEO --> [*]
CANCELADA --> [*]
Implementada em shared/state/VisitStateMachine.kt — a mesma classe roda no app (para habilitar botões) e no servidor (autoridade). Toda transição inválida devolve 409 Conflict com o estado atual.
Temporizadores (todos como linhas em scheduled_jobs, nunca timers em memória):
| Job | Dispara | Ação |
|---|---|---|
VISIT_RING_TIMEOUT |
20s após TOCANDO (kind VISITA) |
→ ESCALONADA, toca para os demais unit_members por ring_order |
VISIT_ESCALATE |
15s após ESCALONADA |
→ FILA_OPERADOR se operator_queue ativo, senão → RECADO_EM_VIDEO |
VISIT_QUEUE_TIMEOUT |
120s em FILA_OPERADOR |
→ RECADO_EM_VIDEO |
VISIT_EXPIRE |
expires_at (10 min) |
→ EXPIRADA |
DELIVERY_DEFAULT_RULE |
15s após TOCANDO em ENTREGA |
aplica units.delivery_rule |
2. Fluxo A — Visita
VISITANTE BACKEND MORADOR
│ │ │
│ 1. GET /v/{gateId}?s=... │ │
├───────────────────────────►│ valida assinatura do QR │
│◄─── aviso de tratamento ───┤ + qr_version │
│ │ │
│ 2. POST /sessions │ │
│ {lat, lng, accuracy} │ ST_DWithin(geofence) │
├───────────────────────────►│ registra privacy_notice │
│◄─── JWT efêmero (15min) ───┤ │
│ │ │
│ 3. POST /visits │ │
│ {kind, nome, bloco, │ BUSCA CEGA: │
│ unidade, foto} │ resolve unit_id ou NULL │
├───────────────────────────►│ resposta idêntica sempre │
│◄──────── {visitId} ────────┤ │
│ │ ── outbox: VisitaCriada ──┤
│ │ │
│ │ push FCM / VoIP+CallKit │
│ ├───────────────────────────►│ toca
│ │ │
│ 4. WS /ws/visitor/{id} │ │ 5. atende
│◄══ estado em tempo real ══►│◄═══════ WS /ws/app ═══════►│
│ │ sala criada em EM_CHAMADA │
│◄── ROOM_READY + token ─────┼──── ROOM_READY + token ───►│
│ │ │
│◄────── LiveKit: sala compartilhada ────────────────────►│
│ │ │
│ │◄── POST /visits/{id}/resolve
│ │ {AUTORIZAR, Idempotency-Key}
│◄─── AUTORIZADA + PIN ──────┤ │
│ ├── notifica responsável ────►
Passo 3 é o ponto crítico de segurança. A resposta é byte-a-byte idêntica para unidade existente e inexistente, com o mesmo tempo de resposta (comparação em tempo constante e delay artificial se necessário). Quem tem o QR não consegue mapear o prédio.
Visita-sombra — a busca cega vale também para a linha do tempo
Não basta a resposta HTTP ser idêntica: se a visita de unidade inexistente ficasse parada em TOCANDO até expirar enquanto a real anda TOCANDO → ESCALONADA → FILA_OPERADOR, o atacante distinguiria os dois casos observando a sequência de estados no WebSocket. A busca cega tem que valer para a linha do tempo inteira.
Por isso, quando unit_id é NULL — ou quando a unidade existe mas não tem nenhum dispositivo ativo — a visita percorre exatamente os mesmos estados, nos mesmos tempos, sem notificar ninguém:
visita-sombra: TOCANDO (20s, sem push) → ESCALONADA (15s, sem push)
├─ operator_queue ativo → FILA_OPERADOR (de verdade,
│ marcada para o operador como "unidade não cadastrada")
└─ sem módulo → RECADO_EM_VIDEO
(listado no admin como "recado sem unidade")
Com o módulo de operador, a visita-sombra entra na fila de verdade: o operador vê a marcação privada "unidade não cadastrada" e trata como um porteiro humano trataria quem errou o número — pergunta, corrige a unidade e redireciona. Efeito colateral desejável: erro de digitação legítimo ("101A" em vez de "101-A") é recuperado em vez de perdido, e a indistinguibilidade fica perfeita — nos dois casos alguém atende.
3. Fluxo B — Entrega (funil rápido)
Meta: p95 de 10s do QR à resposta. Sem vídeo obrigatório.
VISITANTE BACKEND MORADOR
│ 1-2. QR + sessão (idem) │ │
│ │ │
│ 3. POST /visits │ │
│ {kind: ENTREGA, │ │
│ foto do pacote, │ │
│ unidade} │ │
├───────────────────────────►│ │
│ │ push com AÇÕES: │
│ ├───────────────────────────►│
│ │ "Entrega para o 101-A" │
│ │ [Autorizar] [Chamar] │
│ │ │
│ │◄── 1 toque, sem abrir app ─┤
│◄─── AUTORIZADA (≈6s) ──────┤ │
│ │ │
│ ── ou, se 15s sem resposta ── │
│ │ aplica units.delivery_rule│
│◄─ "Deixe na portaria" ─────┤ │
O push carrega ações nativas (Android CallStyle/action buttons, iOS UNNotificationCategory), então o morador resolve sem abrir o app. "Chamar em vídeo" promove a visita para o Fluxo A.
delivery_rule da unidade decide o silêncio: DEIXAR_PORTARIA (default), RECUSAR ou AGUARDAR (mantém tocando até expirar).
4. Fluxo C — Escalonamento e recado
TOCANDO (morador principal, 20s)
└─ ESCALONADA → demais unit_members por ring_order, em paralelo (15s)
├─ operator_queue ATIVO ──► FILA_OPERADOR
│ ├─ operador DISPONIVEL assume ──► EM_CHAMADA
│ └─ 120s sem operador ──────────► RECADO_EM_VIDEO
└─ operator_queue INATIVO ───────────► RECADO_EM_VIDEO
│
visitante grava até 30s de vídeo
→ media_assets(RECADO_VIDEO)
→ notificação ao morador (sem urgência)
→ pendência na home do app
Dentro de quiet_hours do condomínio, visitas não pré-autorizadas pulam o toque ao morador e vão direto para FILA_OPERADOR (ou recado). É a defesa contra tocar em todos os apartamentos de madrugada.
5. Fluxo D — Autorização e abertura
resolve(AUTORIZAR)
│
├─ cria access_grants {pin 6 dígitos, valid_until = now + 5min}
├─ devolve PIN + QR ao visitante
├─ notifica gate_responsibles por priority (foto + nome + unidade + PIN)
└─ AccessControlDevice.open(gate, grant) [v2 — módulo access_control_hardware]
│
└─ registra device_result; falha NÃO invalida o grant
(a conferência humana pelo PIN continua válida)
Na v1 a abertura é humana: quem abre confere o PIN na tela do visitante contra o que recebeu na notificação. AccessControlDevice já existe como porta para não reescrever o fluxo quando o hardware entrar.
6. Endpoints REST
Visitante — /api/v1/visitor
| Método | Rota | Descrição |
|---|---|---|
GET |
/gates/{gateId}/preview?s={sig} |
Valida QR; devolve nome do condomínio e aviso de tratamento. Sem auth |
POST |
/sessions |
{gateId, sig, lat, lng, accuracy} → JWT efêmero. Valida geofence e rate limit |
POST |
/visits |
Cria visita. multipart: JSON + foto. Busca cega |
GET |
/visits/{id} |
Estado atual (fallback de polling se o WS cair) |
POST |
/visits/{id}/message |
Envia recado em vídeo |
POST |
/visits/{id}/cancel |
Visitante desiste |
GET |
/visits/{id}/room-token |
Token LiveKit com escopo da sala — disponível a partir de EM_CHAMADA; antes disso, 409 |
Morador e operador — /api/v1/app
| Método | Rota | Descrição |
|---|---|---|
GET |
/me |
Perfil, unidades, features do tenant |
POST |
/devices |
Registra push_token e voip_token |
GET |
/visits?status=&page= |
Histórico e pendências |
POST |
/visits/{id}/answer |
Atende → EM_CHAMADA + token LiveKit |
POST |
/visits/{id}/resolve |
{decision: AUTORIZAR|NEGAR, reason?} — exige Idempotency-Key |
PATCH |
/units/{id}/delivery-rule |
Regra padrão de entrega |
POST |
/operator/status |
DISPONIVEL / OFFLINE |
POST |
/operator/queue/claim |
Assume a próxima visita da fila |
Admin — /api/v1/admin
CRUD de condominiums, blocks, units, persons, unit_members, gates, gate_responsibles, mais:
| Método | Rota | Descrição |
|---|---|---|
GET |
/visits |
Auditoria com filtros (período, unidade, estado, tipo, dentro/fora do geofence) |
GET |
/visits/{id}/media/{mediaId} |
URL assinada, expira em 5 min, acesso registrado em audit_log |
POST |
/units/import |
Importação CSV com dry-run |
POST |
/persons/{id}/invite |
Convite por link |
GET |
/reconciliation |
Moradores com valid_until vencido ou sem dispositivo ativo |
PATCH |
/features/{key} |
Liga/desliga módulo do tenant |
GET |
/slo |
Painel de SLOs (ver 01-ARQUITETURA.md §5.7) |
POST |
/lgpd/export · /lgpd/erase |
Direitos do titular |
7. Protocolo WebSocket
Dois endpoints, mesmo envelope: /ws/visitor/{visitId}?token= e /ws/app?token=.
{ "type": "VISIT_STATE_CHANGED",
"visitId": "uuid", "ts": "2026-07-22T14:03:11Z",
"data": { "from": "TOCANDO", "to": "ESCALONADA", "version": 3 } }
| Tipo | Direção | Uso |
|---|---|---|
VISIT_STATE_CHANGED |
→ cliente | Toda transição |
INCOMING_VISIT |
→ morador | Chamada entrando (redundante com push, chega antes se o app está aberto) |
ROOM_READY |
→ ambos | Sala LiveKit pronta, com token |
DEGRADED_MODE |
→ ambos | Queda para AUDIO / FOTO_TEXTO / OPERADOR |
QUEUE_POSITION |
→ visitante | Posição na fila do operador |
HEARTBEAT |
↔ | 20s; 3 perdidos = reconecta com backoff |
O WebSocket é otimização de latência, nunca fonte de verdade. Toda mudança de estado é confirmada por REST ou push. Se o WS cair, o cliente faz polling em GET /visits/{id} a cada 3s. Um visitante com WS morto ainda tem a portaria funcionando.
8. Idempotência e concorrência
Toda mutação que muda estado de visita exige Idempotency-Key (UUID gerado pelo cliente):
POST /api/v1/app/visits/{id}/resolve
Idempotency-Key: 7f3a...
{ "decision": "AUTORIZAR" }
- Chave nova → executa e grava resposta em
idempotency_keys(TTL 24h). - Chave repetida com mesmo
request_hash→ devolve a resposta original, sem reexecutar. - Chave repetida com corpo diferente →
422 Unprocessable Entity.
Concorrência entre moradores: resolve usa optimistic locking em visits.version. Se dois moradores respondem juntos, o segundo recebe:
{ "error": "VISIT_ALREADY_RESOLVED",
"currentState": "AUTORIZADA",
"resolvedBy": "Maria Silva",
"resolvedAt": "2026-07-22T14:03:14Z" }
O app mostra "Maria já autorizou" em vez de um erro técnico.
9. Erros
application/problem+json (RFC 7807), com code estável para o cliente ramificar:
{ "type": "https://portaria.app/errors/outside-geofence",
"title": "Fora do perímetro da portaria",
"status": 403, "code": "OUTSIDE_GEOFENCE",
"detail": "Aproxime-se da entrada e tente novamente." }
| Code | HTTP | Quando |
|---|---|---|
INVALID_QR_SIGNATURE |
400 | Assinatura inválida ou qr_version antiga |
OUTSIDE_GEOFENCE |
403 | Fora do raio do portão |
LOCATION_REQUIRED |
403 | Permissão de localização negada |
RATE_LIMITED |
429 | Excesso de tentativas por IP/dispositivo |
QUIET_HOURS |
200* | Em janela de silêncio — segue para operador, não é erro |
VISIT_ALREADY_RESOLVED |
409 | Corrida entre moradores |
INVALID_STATE_TRANSITION |
409 | Transição não permitida |
IDEMPOTENCY_KEY_REUSE |
422 | Mesma chave, corpo diferente |
detail é sempre texto pronto para exibir ao visitante, em português. A tela da portaria não é lugar para mensagem técnica.
10. Eventos de outbox
event_type |
Consumidores |
|---|---|
VisitaCriada |
Notificação, métricas |
VisitaTocando |
Push/VoIP, WebSocket |
VisitaEscalonada |
Push aos demais moradores |
VisitaEnfileiradaOperador |
WebSocket dos operadores disponíveis |
VisitaResolvida |
Notifica responsável pela abertura, auditoria, métricas |
AcessoConcedido |
AccessControlDevice, notificação |
RecadoGravado |
Notificação não-urgente ao morador |
MidiaExpirada |
Job de expurgo |
Entrega ao menos uma vez — todo consumidor é idempotente por (event_type, aggregate_id, attempt).