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

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