Files
Projeto-Portaria/docs/03-FLUXOS-E-CONTRATOS.md

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).