prompt inicial do projeto

This commit is contained in:
2026-07-22 15:55:55 -03:00
commit 6fb920d333
19 changed files with 2937 additions and 0 deletions

240
docs/01-ARQUITETURA.md Normal file
View File

@@ -0,0 +1,240 @@
# 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