# 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 ```mermaid 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=`. ```json { "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): ```http 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: ```json { "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: ```json { "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)`.