Files
Projeto-Portaria/docs/01-ARQUITETURA.md
2026-07-22 15:55:55 -03:00

241 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 01 — Arquitetura
> Documento espinha. Todos os outros dependem deste. Se houver conflito entre documentos, este prevalece sobre decisões de stack e estrutura.
## 1. Visão geral
```
┌─────────────────── Docker Compose ───────────────────┐
QR / Wi-Fi │ │
na portaria │ traefik (TLS automático, roteamento) │
│ │ │ │
▼ │ ├── portaria-backend (Spring Boot, stateless) │
web/visitor ──────┼──────┤ ├── REST (OpenAPI) │
React PWA │ │ ├── WebSocket (sinalização de chamada) │
│ │ ├── Outbox publisher + ShedLock │
apps/ morador ────┼──────┤ └── Resilience4j → LiveKit / FCM / APNs │
operador │ │ │
Compose MP │ ├── postgres 17 + PostGIS │
│ │ └── flyway · outbox · fila · jobs │
web/admin ──────┼──────┤ │
React + TS │ ├── livekit-server (SFU) + coturn (TURN) │
│ ├── minio (fotos, recados, gravações) │
│ └── otel-collector → prometheus/grafana/loki │
└──────────────────────────────────────────────────────┘
FCM (Android) · APNs + PushKit/CallKit (iOS)
```
**Princípio central:** o backend é *stateless*. Todo estado vive no Postgres, no MinIO ou no LiveKit. Nenhuma informação de chamada em andamento, timer de escalonamento ou sessão fica em memória de processo. Isso é o que permite replicar o backend e sobreviver a restart sem deixar visitante preso na porta.
## 2. Stack por camada
| Camada | Tecnologia | Justificativa |
|---|---|---|
| Backend | **Spring Boot 3 + Kotlin** (JDK 21) | Transações declarativas, Actuator, ShedLock, Resilience4j e Spring Security de fábrica. A robustez aqui vem de infraestrutura pronta e testada, não de código nosso |
| Persistência | **PostgreSQL 17 + PostGIS** | Geofence do visitante (`ST_DWithin`), fila durável, outbox e dados de negócio num só lugar transacional |
| Migrations | **Flyway** | Versionamento linear e auditável do schema |
| Acesso a dados | **jOOQ** | SQL tipado. Preferido sobre JPA aqui porque as consultas de auditoria e relatório do admin são analíticas, e JPA atrapalha nesse perfil |
| Mídia | **LiveKit** (self-hosted) + **coturn** | SFU open-source, dockerizado, com SDKs oficiais Android, Swift e JS |
| Objetos | **MinIO** (API S3) | Fotos, recados em vídeo e gravações. Trocável por S3 sem mudar código |
| Apps móveis | **Compose Multiplatform** (Android + iOS) | Uma base de UI para morador e operador |
| Web visitante | **Vite + React + TS** (PWA) | TTI ~1s em 4G. O visitante não instala nada e não espera |
| Web admin | **Vite + React + TS** | Mesma toolchain; ecossistema maduro de tabela densa, mapa e player |
| Compartilhado | **Kotlin Multiplatform** (`shared/`) | DTOs e máquina de estados, únicos entre backend e apps |
| Observabilidade | **OpenTelemetry** → Prometheus / Grafana / Loki | SLOs medidos, não presumidos |
## 3. Estrutura do monorepo
```
Projeto-Portaria/
├── settings.gradle.kts # inclui :backend e :shared
├── shared/ # Kotlin Multiplatform
│ └── src/commonMain/kotlin/br/com/portaria/shared/
│ ├── model/ # entidades de domínio
│ ├── dto/ # kotlinx.serialization — contrato de rede
│ ├── state/ # máquina de estados (VisitStateMachine)
│ └── validation/ # regras compartilhadas
├── backend/ # Spring Boot 3 + Kotlin
│ └── src/main/kotlin/br/com/portaria/
│ ├── domain/ # entidades, agregados, portas
│ ├── application/ # casos de uso (services)
│ ├── adapter/
│ │ ├── in/rest/ # controllers + OpenAPI
│ │ ├── in/ws/ # WebSocket de sinalização
│ │ └── out/ # jooq, livekit, push, storage, notification
│ ├── infra/ # outbox, scheduler, security, config
│ └── resources/db/migration/ # Flyway
├── apps/ # Compose Multiplatform
│ ├── composeApp/ # código comum (morador + operador)
│ ├── androidApp/
│ └── iosApp/
├── web/
│ ├── visitor/ # Vite + React + TS (PWA)
│ ├── admin/ # Vite + React + TS
│ └── shared-ui/ # design tokens + componentes comuns
├── infra/
│ ├── docker-compose.yml
│ ├── docker-compose.prod.yml
│ ├── livekit/ coturn/ traefik/ observability/
└── docs/ prompts/
```
**Gradle** governa `shared/`, `backend/` e `apps/`. **pnpm workspaces** governa `web/`. São dois mundos de build que só se encontram no contrato OpenAPI.
### Fluxo de contratos
```
shared/dto (Kotlin) ──────► backend usa direto
──────► apps Compose usam direto
└──► backend expõe /v3/api-docs (OpenAPI)
└──► openapi-typescript ──► web/*/src/api/types.ts
```
Os webs **nunca** escrevem tipos de API à mão. São gerados no build a partir do OpenAPI, o que faz uma mudança de contrato quebrar o build do front em vez de quebrar em produção.
## 4. O módulo `shared/`
Contém apenas o que precisa ser idêntico entre servidor e app. Não é uma biblioteca de utilidades.
```kotlin
// shared/src/commonMain/kotlin/br/com/portaria/shared/state/VisitState.kt
enum class VisitState {
PENDENTE, TOCANDO, ESCALONADA, FILA_OPERADOR,
EM_CHAMADA, AUTORIZADA, NEGADA, RECADO_EM_VIDEO, EXPIRADA, CANCELADA
}
object VisitStateMachine {
private val transicoes: Map<VisitState, Set<VisitState>> = mapOf(
PENDENTE to setOf(TOCANDO, CANCELADA),
TOCANDO to setOf(EM_CHAMADA, ESCALONADA, AUTORIZADA, NEGADA, CANCELADA),
ESCALONADA to setOf(EM_CHAMADA, FILA_OPERADOR, RECADO_EM_VIDEO, AUTORIZADA, NEGADA, EXPIRADA),
// ...
)
fun permite(de: VisitState, para: VisitState) = para in (transicoes[de] ?: emptySet())
fun ehFinal(estado: VisitState) = estado in setOf(AUTORIZADA, NEGADA, EXPIRADA, CANCELADA, RECADO_EM_VIDEO)
}
```
A mesma classe valida no app (para desabilitar botões) e no servidor (para rejeitar comandos inválidos). O servidor é a autoridade — o cliente só antecipa.
## 5. Os sete padrões de robustez
Não são recomendações. São o núcleo de confiabilidade do sistema, e cada um existe porque há um modo de falha concreto em que gente fica presa na porta.
### 5.1 Transactional outbox
**Falha que previne:** a visita é criada, o processo cai antes de publicar a notificação, e o morador nunca fica sabendo que há alguém na porta.
```kotlin
@Transactional
fun criarVisita(cmd: CriarVisitaCommand): Visit {
val visita = visitRepository.save(Visit.nova(cmd))
outbox.enfileirar(VisitaCriada(visita.id, visita.unitId)) // mesma transação
return visita
}
```
Um publisher separado lê a `outbox`, despacha e marca como publicado, com retry e backoff exponencial. Entrega *ao menos uma vez* — por isso os consumidores são idempotentes.
### 5.2 Timeouts como jobs persistidos
**Falha que previne:** o processo que segurava o timer de 20 segundos morre e o escalonamento nunca acontece.
Nunca `delay()`, `Timer` ou `@Scheduled` de instância única guardando estado. O timeout é uma **linha na tabela `scheduled_jobs`** com `run_at`. Um poller com **ShedLock** garante que apenas uma réplica execute cada job.
```kotlin
@Scheduled(fixedDelay = 1000)
@SchedulerLock(name = "visit-timeouts", lockAtMostFor = "30s")
fun processarTimeouts() { /* SELECT ... FOR UPDATE SKIP LOCKED */ }
```
### 5.3 Optimistic locking
**Falha que previne:** dois moradores da mesma unidade respondem juntos; um autoriza, o outro nega, e o último a gravar vence em silêncio.
Coluna `version` em `visits`. Conflito devolve `409` com o estado atual, e o app mostra "outro morador já respondeu".
### 5.4 Idempotência
**Falha que previne:** morador com 4G ruim toca "Autorizar" três vezes e gera três autorizações.
Toda mutação aceita header `Idempotency-Key`. A tabela `idempotency_keys` guarda a resposta por 24h; repetição devolve a resposta original sem reexecutar.
### 5.5 Degradação graciosa
**Falha que previne:** LiveKit fora do ar significa prédio trancado.
```
LiveKit indisponível (circuit breaker aberto)
└─► modo ÁUDIO (menos banda, mesma sala)
└─► modo FOTO+TEXTO (visitante manda foto, morador aprova sem chamada)
└─► FILA_OPERADOR (humano resolve por telefone)
```
O nível de degradação vigente é exposto em `/actuator/health` e visível no painel admin.
### 5.6 Fila durável em Postgres
`SELECT ... FOR UPDATE SKIP LOCKED` sobre a tabela `job_queue`. Redis existe apenas para o LiveKit (obrigatório em multi-instância) e para rate limiting — **nunca** para dados que não podem se perder. Menos peças móveis, e o enfileiramento participa da mesma transação dos dados.
### 5.7 SLO medido
| SLO | Alvo | Métrica |
|---|---|---|
| QR → celular do morador tocando | p95 < 5s | `portaria.visit.ring_latency` |
| Taxa de atendimento pelo morador | > 70% | `portaria.visit.answered_ratio` |
| QR → resposta em entrega | p95 < 10s | `portaria.delivery.resolution_latency` |
| Abandono do entregador | < 10% | `portaria.delivery.abandoned_ratio` |
| Disponibilidade do fluxo de entrada | 99,5% | uptime do caminho crítico |
Sem essas métricas não se sabe se o produto está funcionando — a falha aqui é silenciosa por natureza.
## 6. Segurança — visão arquitetural
Três identidades distintas, três mecanismos:
| Quem | Autenticação | Duração |
|---|---|---|
| **Visitante** | JWT efêmero emitido ao validar o QR + geofence. Sem cadastro | 15 min, escopo de uma visita |
| **Morador / operador** | OIDC (Spring Security) + refresh token no keystore do dispositivo | Access 15 min, refresh 30 dias |
| **Admin** | OIDC + MFA obrigatório | Sessão de 8h |
**Busca cega de unidade:** o endpoint de destino recebe bloco + unidade e **nunca** confirma se existe ou quem mora lá. Resposta idêntica para unidade válida e inválida; se não existir, a chamada simplesmente não é atendida. Isso impede que qualquer pessoa com o QR enumere quem mora onde — um problema de segurança física antes de ser de privacidade. Detalhes em `06-LGPD-E-SEGURANCA.md`.
## 7. Multi-tenant preparado, não ativado
Toda tabela de negócio tem `tenant_id NOT NULL`. Todo repositório filtra por ele. Policies de **Row Level Security** são criadas nas migrations mas ficam `DISABLE`d na v1.
```sql
CREATE POLICY tenant_isolation ON visits
USING (tenant_id = current_setting('app.current_tenant')::uuid);
ALTER TABLE visits DISABLE ROW LEVEL SECURITY; -- v1
```
Ativar multi-tenancy vira `ENABLE ROW LEVEL SECURITY` + a UI de gestão. Sem isso, adicionar `tenant_id` depois seria reescrever todo o schema e toda query.
## 8. ADRs — decisões e o que se abriu mão
**ADR-001 · Spring Boot em vez de Ktor.** Ktor é mais leve e idiomático, mas minimalista: agendamento, retry, métricas, transações e circuit breaker seriam código nosso. Num sistema onde falha silenciosa significa gente presa na porta, preferimos infraestrutura madura à elegância. *Custo:* mais cerimônia, startup mais lento, imagem maior.
**ADR-002 · React em vez de Compose/Wasm na web.** O bundle Wasm (38MB) daria TTI de 36s em 4G na tela do visitante — abandono garantido na portaria. *Custo:* a UI web não é compartilhada com os apps; só os contratos são.
**ADR-003 · LiveKit self-hosted em vez de gerenciado.** Atende o requisito de infra dockerizada e evita custo por minuto no núcleo do produto. *Custo:* operar SFU, TURN e banda. Mitigado por `VideoCallProvider`, que permite trocar por gerenciado sem tocar nos apps.
**ADR-004 · jOOQ em vez de JPA.** As consultas mais complexas do sistema são analíticas (auditoria, relatórios, filtros do admin), onde JPA atrapalha. *Custo:* passo de geração de código no build.
**ADR-005 · Fila em Postgres em vez de Redis/Kafka.** Volume real é de ~0,3 req/s; Kafka seria desproporcional. Postgres dá durabilidade e transacionalidade com os dados. *Custo:* não escala para milhões de mensagens — irrelevante nesta ordem de grandeza.
**ADR-006 · Legítimo interesse em vez de consentimento.** Consentimento sob "aceite ou não entre" não é livre e não é base legal válida. Ver `06-LGPD-E-SEGURANCA.md`.
**ADR-007 · Sem reconhecimento facial na v1.** Elevaria os dados a *sensíveis* (LGPD art. 11), com exigência jurídica muito maior. Decisão de produto com fundamento legal, não limitação técnica.
## 9. Referências
- `02-MODELO-DE-DADOS.md` — schema, outbox, fila, versionamento
- `03-FLUXOS-E-CONTRATOS.md` — máquinas de estado e API
- `05-INFRA-DOCKER.md` — compose, portas, dimensionamento
- `06-LGPD-E-SEGURANCA.md` — bases legais e modelo de ameaças