296 lines
16 KiB
Markdown
296 lines
16 KiB
Markdown
# 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)`.
|