prompt inicial do projeto
This commit is contained in:
277
docs/03-FLUXOS-E-CONTRATOS.md
Normal file
277
docs/03-FLUXOS-E-CONTRATOS.md
Normal file
@@ -0,0 +1,277 @@
|
||||
# 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` | → `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, roomToken} ──┤ │
|
||||
│ │ ── outbox: VisitaCriada ──┤
|
||||
│ │ │
|
||||
│ │ push FCM / VoIP+CallKit │
|
||||
│ ├───────────────────────────►│ toca
|
||||
│ │ │
|
||||
│ 4. WS /ws/visitor/{id} │ │ 5. atende
|
||||
│◄══ estado em tempo real ══►│◄═══════ WS /ws/app ═══════►│
|
||||
│ │ │
|
||||
│◄────── 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). Se a unidade não existe, a visita entra em `TOCANDO`, ninguém é notificado, e ela expira normalmente. Quem tem o QR não consegue mapear o prédio.
|
||||
|
||||
## 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 |
|
||||
|
||||
### 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)`.
|
||||
Reference in New Issue
Block a user