prompt inicial do projeto
This commit is contained in:
69
README.md
Normal file
69
README.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Projeto Portaria
|
||||
|
||||
Portaria virtual para prédios residenciais e comerciais. O visitante escaneia um QR na entrada, o morador valida por videochamada e autoriza a entrada — com operador humano de retaguarda para quando ninguém atende.
|
||||
|
||||
**Este repositório contém, por enquanto, a especificação e os prompts de implementação.** Nenhum código de aplicação foi escrito ainda.
|
||||
|
||||
---
|
||||
|
||||
## Como usar
|
||||
|
||||
Leia `docs/` na ordem, depois execute `prompts/` fase por fase. Cada prompt referencia as seções dos documentos que precisa respeitar — os documentos são a fonte de verdade, os prompts são o roteiro.
|
||||
|
||||
**Não pule fases e não junte duas num prompt só.** O fatiamento existe porque as áreas de maior risco (WebRTC, notificação de chamada no iOS, LGPD) são exatamente as que um prompt monolítico implementa pela metade.
|
||||
|
||||
## Documentos
|
||||
|
||||
| Documento | Conteúdo |
|
||||
|---|---|
|
||||
| [`00-VISAO-E-PRODUTO`](docs/00-VISAO-E-PRODUTO.md) | Problema, personas, jornadas, planos comerciais, escopo, métricas |
|
||||
| [`01-ARQUITETURA`](docs/01-ARQUITETURA.md) | **Espinha.** Stack, monorepo, os 7 padrões de robustez, ADRs |
|
||||
| [`02-MODELO-DE-DADOS`](docs/02-MODELO-DE-DADOS.md) | Schema Postgres, outbox, fila, retenção, RLS preparada |
|
||||
| [`03-FLUXOS-E-CONTRATOS`](docs/03-FLUXOS-E-CONTRATOS.md) | Máquinas de estado, REST, WebSocket, idempotência |
|
||||
| [`04-DESIGN-SYSTEM-UX`](docs/04-DESIGN-SYSTEM-UX.md) | Tokens, telas, movimento, acessibilidade |
|
||||
| [`05-INFRA-DOCKER`](docs/05-INFRA-DOCKER.md) | Compose, LiveKit, portas, banda, observabilidade, CI/CD |
|
||||
| [`06-LGPD-E-SEGURANCA`](docs/06-LGPD-E-SEGURANCA.md) | Bases legais, modelo de ameaças, direitos do titular |
|
||||
| [`07-REQUISITOS-DE-CAMPO`](docs/07-REQUISITOS-DE-CAMPO.md) | **Pré-requisitos contratuais de instalação**, não backlog |
|
||||
|
||||
## Fases
|
||||
|
||||
| Fase | Entrega |
|
||||
|---|---|
|
||||
| [0 — Bootstrap](prompts/FASE-0-bootstrap.md) | Monorepo, Docker, CI verde |
|
||||
| [1 — Domínio e dados](prompts/FASE-1-dominio-e-dados.md) | `shared/` KMP, schema, outbox, fila, auth |
|
||||
| [2 — API](prompts/FASE-2-backend-api.md) | REST, WebSocket, mídia, ciclo de vida da visita |
|
||||
| [3 — Web do visitante](prompts/FASE-3-web-visitante.md) | PWA, ambos os funis, espera narrada |
|
||||
| [4 — Chamada](prompts/FASE-4-chamada.md) | LiveKit, tokens, degradação graciosa |
|
||||
| [5 — App do morador](prompts/FASE-5-app-morador.md) | Compose MP, FCM, PushKit/CallKit |
|
||||
| [6 — Painel admin](prompts/FASE-6-painel-admin.md) | Auditoria, gestão, reconciliação |
|
||||
| [7 — Escalonamento](prompts/FASE-7-escalonamento.md) | Fila de operador, app do operador, recado |
|
||||
| [8 — Planos e LGPD](prompts/FASE-8-planos-e-lgpd.md) | Feature flags, retenção, direitos, SLOs |
|
||||
| [9 — Hardening](prompts/FASE-9-hardening.md) | E2E, carga, falhas, segurança, piloto |
|
||||
|
||||
## Stack
|
||||
|
||||
**Backend** Spring Boot 3 + Kotlin · PostgreSQL 17 + PostGIS · jOOQ · Flyway
|
||||
**Apps** Compose Multiplatform (Android + iOS) · **Web** Vite + React + TS
|
||||
**Compartilhado** Kotlin Multiplatform (DTOs + máquina de estados)
|
||||
**Mídia** LiveKit self-hosted + coturn · **Objetos** MinIO/S3
|
||||
**Infra** Docker Compose + Traefik · OpenTelemetry + Prometheus + Grafana + Loki
|
||||
|
||||
## As decisões que mais importam
|
||||
|
||||
**O modelo é híbrido, não autônomo.** O morador é chamado primeiro — é a tese do produto. Mas a taxa de atendimento nunca é 100%, e o mercado consolidado usa operador humano 24h justamente por isso. Por isso vendemos dois planos, com o operador como retaguarda paga.
|
||||
|
||||
**Entrega tem funil próprio.** É a maioria do volume, e o entregador não espera videochamada. Foto do pacote, um toque, meta de 10s.
|
||||
|
||||
**A busca de unidade é cega.** A API nunca confirma se uma unidade existe — nem pelo corpo da resposta, nem pelo tempo. Sem isso, qualquer pessoa com o QR mapeia quem mora onde.
|
||||
|
||||
**A base legal é legítimo interesse, não consentimento.** Consentimento sob "aceite ou não entre" não é livre, e portanto não é válido. O que se faz é informar com clareza e arquivar a LIA.
|
||||
|
||||
**Sem reconhecimento facial.** Elevaria os dados a sensíveis, sem base legal disponível. Decisão jurídica, não limitação técnica.
|
||||
|
||||
**iOS exige PushKit + CallKit.** Notificação comum não faz o telefone tocar como chamada. Sem isso, o app não funciona como portaria.
|
||||
|
||||
**Wi-Fi e botão físico são contratuais.** Hall blindado não pega 4G, e nem todo visitante tem smartphone com bateria. Nenhum código resolve isso.
|
||||
|
||||
## Primeiro passo
|
||||
|
||||
Antes da FASE 4, derrube o maior risco técnico: suba `livekit-server` + `coturn` e feche uma chamada entre um navegador móvel em 4G real e um Android, medindo o tempo até o primeiro frame. É barato de testar e caro de descobrir tarde.
|
||||
116
docs/00-VISAO-E-PRODUTO.md
Normal file
116
docs/00-VISAO-E-PRODUTO.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# 00 — Visão e produto
|
||||
|
||||
## 1. O problema
|
||||
|
||||
Portaria física custa caro: três porteiros em turno somam encargos, férias, rotatividade e treinamento. Condomínios brasileiros vêm migrando para portaria remota — cerca de 12% dos síndicos já usam, com NPS 48 e redução de até 50% no custo de segurança.
|
||||
|
||||
Mas as soluções estabelecidas do mercado funcionam com **interfone inteligente + operador humano remoto 24h**. O condomínio troca o porteiro do prédio por um operador em central. Economiza, mas continua pagando por gente de plantão.
|
||||
|
||||
**Nossa aposta:** na maioria das interações, quem decide quem entra é o próprio morador — não um operador. Se o morador atende direto pelo vídeo, o operador vira exceção em vez de regra, e o custo cai de novo.
|
||||
|
||||
**O risco dessa aposta, assumido de olhos abertos:** a taxa de atendimento do morador não é 100%. Gente em reunião, dirigindo, dormindo, com o celular no silencioso. Por isso o produto não vende "sem operador" — vende **dois planos**, e o operador é retaguarda paga para quem quiser cobertura.
|
||||
|
||||
## 2. Personas
|
||||
|
||||
| Persona | Contexto | O que não pode acontecer |
|
||||
|---|---|---|
|
||||
| **Visitante** | Em pé na calçada, no sol, celular na mão, talvez sem saber português direito | Esperar mais de 30s sem feedback, ou precisar instalar app |
|
||||
| **Entregador** | Tem mais 12 entregas, moto ligada, pressa real | Videochamada obrigatória. Ele vai embora ou larga na calçada |
|
||||
| **Morador** | Trabalhando, dirigindo, dormindo | Chamada que não toca de verdade (o caso do iOS sem CallKit) |
|
||||
| **Responsável pela abertura** | Zelador ou segurança no local | Receber autorização sem saber quem é a pessoa |
|
||||
| **Operador** | Central remota, várias visitas em paralelo | Fila sem contexto: precisa ver foto e histórico antes de atender |
|
||||
| **Síndico / administradora** | Presta contas à assembleia; responde juridicamente | Não conseguir provar o que aconteceu numa ocorrência |
|
||||
|
||||
## 3. Jornadas
|
||||
|
||||
### Visita (o caso "nobre", minoria do volume)
|
||||
|
||||
Chega → escaneia QR → vê aviso de tratamento de dados → permite câmera e localização → tira foto → informa bloco e unidade → **espera** → morador aparece no vídeo → conversa → autorizado → recebe PIN → o responsável pela abertura é notificado com a foto e abre.
|
||||
|
||||
**Ponto de maior atrito:** a espera. O visitante precisa ver progresso real ("chamando o morador", "chamando outros moradores da unidade", "transferindo para a portaria"), nunca um spinner mudo. Um spinner de 35 segundos é indistinguível de um app quebrado.
|
||||
|
||||
### Entrega (a maioria do volume)
|
||||
|
||||
Chega → escaneia QR → **[Entrega]** → foto do pacote → unidade → morador recebe push com dois botões e resolve sem abrir o app → "pode deixar na portaria" em ~6 segundos.
|
||||
|
||||
**Se ninguém responde em 15s**, aplica-se a regra padrão da unidade. O entregador nunca fica parado sem instrução.
|
||||
|
||||
### Prestador de serviço
|
||||
|
||||
Eletricista que fica 4h, mudança com caminhão. Fluxo de visita, mas o grant tem validade estendida e o registro guarda entrada e saída. **v2.**
|
||||
|
||||
### Sem smartphone / sem sinal / sem bateria
|
||||
|
||||
Botão físico na portaria → mesmo fluxo, sem celular nenhum. Ver `07-REQUISITOS-DE-CAMPO.md`.
|
||||
|
||||
## 4. Planos
|
||||
|
||||
| | **Autônomo** | **Assistido 24h** |
|
||||
|---|---|---|
|
||||
| Chamada para o morador | ✓ | ✓ |
|
||||
| Escalonamento na unidade | ✓ | ✓ |
|
||||
| Funil rápido de entrega | ✓ | ✓ |
|
||||
| Recado em vídeo | ✓ | ✓ |
|
||||
| **Fila de operador humano** | — | ✓ |
|
||||
| Promessa | "Você decide quem entra" | "Nunca fica sem resposta" |
|
||||
| Custo operacional | Só infraestrutura | Infraestrutura + plantão |
|
||||
|
||||
**Add-ons:** notificações por WhatsApp, gravação de chamadas, integração com hardware de portão, autorizações recorrentes.
|
||||
|
||||
Na v1 a cobrança é manual (contrato/boleto). O sistema controla apenas quais módulos estão ligados por tenant.
|
||||
|
||||
## 5. Escopo
|
||||
|
||||
### v1
|
||||
|
||||
Fluxo de visita completo, funil rápido de entrega, escalonamento com fila de operador, recado em vídeo, app do morador (Android + iOS), app do operador, painel admin com auditoria, planos e feature flags, LGPD (aviso, retenção, expurgo, exportação), PIN de abertura, um condomínio por instância com `tenant_id` já no schema.
|
||||
|
||||
### v2
|
||||
|
||||
Integração com hardware de portão, WhatsApp, autorizações recorrentes e pré-autorização, fluxo de prestador com entrada/saída, multi-tenancy ativado, billing automatizado, app do porteiro presencial (modo híbrido).
|
||||
|
||||
### Fora de escopo, com motivo
|
||||
|
||||
**Reconhecimento facial.** Eleva os dados a *sensíveis* na LGPD (art. 11), com exigência jurídica muito maior. Decisão de produto com fundamento legal — ver `06-LGPD-E-SEGURANCA.md`.
|
||||
|
||||
**Substituir 100% da portaria física.** Enquanto a abertura depender de humano e houver queda de energia ou internet, o condomínio precisa de contingência. Vender autonomia total seria desonesto.
|
||||
|
||||
## 6. Métricas
|
||||
|
||||
### Norte
|
||||
|
||||
**Visitas resolvidas sem operador humano.** É a tese do produto. Abaixo de ~70%, o modelo autônomo não se sustenta e o Assistido vira o produto principal.
|
||||
|
||||
### SLOs
|
||||
|
||||
| Métrica | Alvo |
|
||||
|---|---|
|
||||
| QR → celular do morador tocando (p95) | < 5s |
|
||||
| Taxa de atendimento pelo morador | > 70% |
|
||||
| QR → resposta em entrega (p95) | < 10s |
|
||||
| Abandono do entregador | < 10% |
|
||||
| Disponibilidade do fluxo de entrada | 99,5% |
|
||||
|
||||
### Saúde da operação
|
||||
|
||||
Visitas por dia e por tipo · fila de operador (tempo médio e pico) · **push falhados por dispositivo** (detecta token morto antes de virar reclamação) · moradores sem app instalado por condomínio · recados não resolvidos em 24h.
|
||||
|
||||
**Alerta de negócio:** se a taxa de atendimento de um condomínio cai abaixo de 50% por 7 dias, algo está errado — moradores sem app, tokens expirados ou base desatualizada. Vira tarefa de customer success, não incidente técnico.
|
||||
|
||||
## 7. Riscos
|
||||
|
||||
| Risco | Mitigação |
|
||||
|---|---|
|
||||
| Morador não atende com frequência | Plano Assistido, escalonamento, regra padrão de entrega, alerta de baixa adesão |
|
||||
| Entregador abandona | Funil de 1 toque com meta de 10s e regra padrão |
|
||||
| Base de moradores desatualiza | `valid_until` + relatório de reconciliação + importação CSV |
|
||||
| Hall sem sinal de celular | Wi-Fi de visitante como requisito contratual |
|
||||
| Autorização indevida gera responsabilidade | Auditoria íntegra com hash de mídia; contrato define responsabilidade |
|
||||
| Concorrência estabelecida | Diferencial é custo e experiência do morador, não substituir a central |
|
||||
| Sistema cai e ninguém entra | Modo degradado + contingência física contratada |
|
||||
|
||||
## 8. Referências de mercado
|
||||
|
||||
- [SíndicoNet — adoção e economia da portaria remota](https://www.sindiconet.com.br/informese/economia-para-condominios-manutencao-portaria-virtual)
|
||||
- [TownSq — como funciona a portaria remota](https://blog.townsq.com.br/inovacao/portaria-remota/)
|
||||
- [ConJur — reconhecimento facial em condomínios sob a LGPD](https://www.conjur.com.br/2024-abr-07/reconhecimento-facial-em-condominios-desafios-sob-a-otica-da-lgpd/)
|
||||
240
docs/01-ARQUITETURA.md
Normal file
240
docs/01-ARQUITETURA.md
Normal 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 (3–8MB) daria TTI de 3–6s 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
|
||||
403
docs/02-MODELO-DE-DADOS.md
Normal file
403
docs/02-MODELO-DE-DADOS.md
Normal file
@@ -0,0 +1,403 @@
|
||||
# 02 — Modelo de dados
|
||||
|
||||
> PostgreSQL 17 + PostGIS. Migrations em `backend/src/main/resources/db/migration/` via Flyway.
|
||||
> Convenções: `snake_case`, PKs `uuid` (`gen_random_uuid()`), timestamps `timestamptz`, soft delete só onde há exigência de auditoria.
|
||||
|
||||
## Regras invioláveis
|
||||
|
||||
1. **Toda tabela de negócio tem `tenant_id uuid NOT NULL`** — mesmo com multi-tenancy desativado na v1. Adicionar depois seria reescrever schema e queries.
|
||||
2. **Nada de `ON DELETE CASCADE` em dado auditável.** Visitas, mídias e logs sobrevivem à exclusão de unidades e moradores — são prova jurídica.
|
||||
3. **`version integer NOT NULL DEFAULT 0`** em toda tabela com escrita concorrente.
|
||||
4. **Timestamps sempre `timestamptz`.** Nunca `timestamp`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Tenancy e planos
|
||||
|
||||
```sql
|
||||
CREATE TABLE tenants (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name text NOT NULL,
|
||||
document text, -- CNPJ da administradora
|
||||
plan_id uuid NOT NULL REFERENCES plans(id),
|
||||
status text NOT NULL DEFAULT 'ATIVO', -- ATIVO | SUSPENSO | CANCELADO
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
version integer NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE plans (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code text NOT NULL UNIQUE, -- AUTONOMO | ASSISTIDO_24H
|
||||
name text NOT NULL,
|
||||
base_price numeric(10,2), -- referência comercial; cobrança é manual na v1
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Módulos ligáveis por tenant. A flag manda; o plano é só o default aplicado na criação.
|
||||
CREATE TABLE tenant_features (
|
||||
tenant_id uuid NOT NULL REFERENCES tenants(id),
|
||||
feature_key text NOT NULL,
|
||||
enabled boolean NOT NULL DEFAULT false,
|
||||
quota integer, -- NULL = ilimitado (ex.: minutos de vídeo/mês)
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_by uuid,
|
||||
PRIMARY KEY (tenant_id, feature_key)
|
||||
);
|
||||
```
|
||||
|
||||
**Chaves de feature:** `whatsapp_notifications`, `operator_queue`, `video_recording`, `access_control_hardware`, `recurring_authorizations`.
|
||||
|
||||
## 2. Estrutura física do condomínio
|
||||
|
||||
```sql
|
||||
CREATE TABLE condominiums (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id uuid NOT NULL REFERENCES tenants(id),
|
||||
name text NOT NULL,
|
||||
address text NOT NULL,
|
||||
timezone text NOT NULL DEFAULT 'America/Sao_Paulo',
|
||||
quiet_hours int4range, -- janela de silêncio, ex.: [22,7)
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
version integer NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE blocks (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id uuid NOT NULL,
|
||||
condominium_id uuid NOT NULL REFERENCES condominiums(id),
|
||||
name text NOT NULL, -- "A", "Torre Norte"
|
||||
UNIQUE (condominium_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE units (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id uuid NOT NULL,
|
||||
block_id uuid NOT NULL REFERENCES blocks(id),
|
||||
identifier text NOT NULL, -- "101", "Sala 42"
|
||||
-- regra padrão de entrega quando ninguém responde em 15s
|
||||
delivery_rule text NOT NULL DEFAULT 'DEIXAR_PORTARIA', -- DEIXAR_PORTARIA | RECUSAR | AGUARDAR
|
||||
active boolean NOT NULL DEFAULT true,
|
||||
version integer NOT NULL DEFAULT 0,
|
||||
UNIQUE (block_id, identifier)
|
||||
);
|
||||
```
|
||||
|
||||
`quiet_hours` alimenta a defesa contra "tocar em todos os apartamentos de madrugada": dentro da janela, visitas não-pré-autorizadas vão direto para a fila de operador em vez de acordar moradores.
|
||||
|
||||
## 3. Pessoas e dispositivos
|
||||
|
||||
```sql
|
||||
CREATE TABLE persons (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id uuid NOT NULL,
|
||||
name text NOT NULL,
|
||||
email text,
|
||||
phone text, -- E.164
|
||||
auth_subject text UNIQUE, -- sub do OIDC; NULL até aceitar o convite
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
version integer NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- Uma pessoa pode estar em várias unidades (e uma unidade tem vários moradores)
|
||||
CREATE TABLE unit_members (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id uuid NOT NULL,
|
||||
unit_id uuid NOT NULL REFERENCES units(id),
|
||||
person_id uuid NOT NULL REFERENCES persons(id),
|
||||
role text NOT NULL, -- PROPRIETARIO | INQUILINO | DEPENDENTE
|
||||
ring_order smallint NOT NULL DEFAULT 0,-- ordem de escalonamento dentro da unidade
|
||||
receives_calls boolean NOT NULL DEFAULT true,
|
||||
valid_from date NOT NULL DEFAULT CURRENT_DATE,
|
||||
valid_until date, -- reconciliação com a administradora
|
||||
UNIQUE (unit_id, person_id)
|
||||
);
|
||||
|
||||
CREATE TABLE devices (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id uuid NOT NULL,
|
||||
person_id uuid NOT NULL REFERENCES persons(id),
|
||||
platform text NOT NULL, -- ANDROID | IOS
|
||||
push_token text NOT NULL, -- FCM token
|
||||
voip_token text, -- APNs PushKit — obrigatório no iOS
|
||||
app_version text,
|
||||
last_seen_at timestamptz,
|
||||
active boolean NOT NULL DEFAULT true,
|
||||
UNIQUE (platform, push_token)
|
||||
);
|
||||
```
|
||||
|
||||
**`voip_token` separado não é redundância.** No iOS, notificação comum não faz o telefone tocar como chamada — só PushKit + CallKit fazem, e o token do PushKit é distinto do token APNs normal. Sem essa coluna, o app iOS não funciona como portaria.
|
||||
|
||||
**`ring_order`** define a ordem do escalonamento dentro da unidade. `valid_until` é o gancho da reconciliação periódica com a administradora — morador que saiu para de receber chamadas sem precisar excluir histórico.
|
||||
|
||||
### Responsáveis pela abertura e operadores
|
||||
|
||||
```sql
|
||||
CREATE TABLE gate_responsibles (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id uuid NOT NULL,
|
||||
gate_id uuid NOT NULL REFERENCES gates(id),
|
||||
person_id uuid NOT NULL REFERENCES persons(id),
|
||||
priority smallint NOT NULL DEFAULT 0,
|
||||
active boolean NOT NULL DEFAULT true
|
||||
);
|
||||
|
||||
CREATE TABLE operators (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id uuid NOT NULL,
|
||||
person_id uuid NOT NULL REFERENCES persons(id),
|
||||
status text NOT NULL DEFAULT 'OFFLINE', -- OFFLINE | DISPONIVEL | EM_ATENDIMENTO
|
||||
status_since timestamptz NOT NULL DEFAULT now(),
|
||||
version integer NOT NULL DEFAULT 0
|
||||
);
|
||||
```
|
||||
|
||||
## 4. Portarias e QR
|
||||
|
||||
```sql
|
||||
CREATE TABLE gates (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id uuid NOT NULL,
|
||||
condominium_id uuid NOT NULL REFERENCES condominiums(id),
|
||||
name text NOT NULL, -- "Portaria Social", "Garagem"
|
||||
location geography(Point, 4326) NOT NULL,
|
||||
geofence_meters integer NOT NULL DEFAULT 80,
|
||||
qr_secret text NOT NULL, -- segredo de assinatura do QR
|
||||
qr_version integer NOT NULL DEFAULT 1, -- incrementar reimprime o QR e invalida o antigo
|
||||
active boolean NOT NULL DEFAULT true,
|
||||
version integer NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX idx_gates_location ON gates USING GIST (location);
|
||||
```
|
||||
|
||||
O QR impresso não rotaciona, então a defesa é em camadas — a validação de que o visitante está **dentro de `geofence_meters` do portão** é a mais forte delas. A permissão de localização deixa de ser só auditoria e vira controle anti-fraude.
|
||||
|
||||
`qr_version` permite invalidar QRs vazados: incrementa a versão, reimprime a placa, e os códigos antigos param de validar.
|
||||
|
||||
## 5. Visitas — o agregado central
|
||||
|
||||
```sql
|
||||
CREATE TABLE visits (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id uuid NOT NULL,
|
||||
gate_id uuid NOT NULL REFERENCES gates(id),
|
||||
unit_id uuid REFERENCES units(id), -- NULL se a unidade informada não existe (busca cega)
|
||||
unit_input text NOT NULL, -- o que o visitante digitou, sempre preservado
|
||||
kind text NOT NULL, -- VISITA | ENTREGA | PRESTADOR
|
||||
state text NOT NULL DEFAULT 'PENDENTE',
|
||||
|
||||
visitor_name text NOT NULL,
|
||||
visitor_document text,
|
||||
visitor_phone text,
|
||||
visitor_location geography(Point, 4326),
|
||||
location_accuracy real,
|
||||
inside_geofence boolean,
|
||||
|
||||
room_name text, -- sala LiveKit
|
||||
answered_by uuid REFERENCES persons(id),
|
||||
resolved_by uuid REFERENCES persons(id),
|
||||
resolution_reason text,
|
||||
degraded_mode text, -- NULL | AUDIO | FOTO_TEXTO | OPERADOR
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
ringing_at timestamptz,
|
||||
answered_at timestamptz,
|
||||
resolved_at timestamptz,
|
||||
expires_at timestamptz NOT NULL,
|
||||
version integer NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX idx_visits_unit_created ON visits (tenant_id, unit_id, created_at DESC);
|
||||
CREATE INDEX idx_visits_state_active ON visits (state, expires_at)
|
||||
WHERE state NOT IN ('AUTORIZADA','NEGADA','EXPIRADA','CANCELADA','RECADO_EM_VIDEO');
|
||||
```
|
||||
|
||||
**`unit_id` nulo com `unit_input` preenchido é o coração da busca cega.** Se o visitante digitar uma unidade inexistente, a visita é criada do mesmo jeito, entra em `TOCANDO` e simplesmente não é atendida — a resposta da API é idêntica à de uma unidade válida. Um atacante com o QR não consegue distinguir "apartamento não existe" de "ninguém atendeu", e portanto não consegue mapear o prédio.
|
||||
|
||||
`answered_by` e `resolved_by` são separados de propósito: quem atendeu a chamada pode não ser quem decidiu (operador atende, morador decide).
|
||||
|
||||
### Tentativas de toque
|
||||
|
||||
```sql
|
||||
CREATE TABLE visit_attempts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id uuid NOT NULL,
|
||||
visit_id uuid NOT NULL REFERENCES visits(id),
|
||||
person_id uuid REFERENCES persons(id),
|
||||
target_kind text NOT NULL, -- MORADOR | OUTROS_MORADORES | OPERADOR | RESPONSAVEL_ABERTURA
|
||||
channel text NOT NULL, -- PUSH | VOIP | WHATSAPP | WEBSOCKET
|
||||
sent_at timestamptz NOT NULL DEFAULT now(),
|
||||
delivered_at timestamptz,
|
||||
answered_at timestamptz,
|
||||
failure text
|
||||
);
|
||||
```
|
||||
|
||||
Esta tabela é o que permite responder "por que ninguém atendeu?" — push não entregue, token expirado, morador sem app. Sem ela, a falha mais comum do produto fica invisível.
|
||||
|
||||
## 6. Mídia, autorizações e consentimento
|
||||
|
||||
```sql
|
||||
CREATE TABLE media_assets (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id uuid NOT NULL,
|
||||
visit_id uuid REFERENCES visits(id),
|
||||
kind text NOT NULL, -- FOTO_VISITANTE | FOTO_PACOTE | RECADO_VIDEO | GRAVACAO
|
||||
storage_key text NOT NULL, -- caminho no MinIO/S3
|
||||
content_type text NOT NULL,
|
||||
size_bytes bigint,
|
||||
sha256 text NOT NULL, -- integridade: prova que a mídia não foi alterada
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz NOT NULL -- expurgo automático (LGPD)
|
||||
);
|
||||
CREATE INDEX idx_media_expiry ON media_assets (expires_at) WHERE expires_at IS NOT NULL;
|
||||
|
||||
CREATE TABLE access_grants (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id uuid NOT NULL,
|
||||
visit_id uuid NOT NULL REFERENCES visits(id),
|
||||
gate_id uuid NOT NULL REFERENCES gates(id),
|
||||
granted_by uuid NOT NULL REFERENCES persons(id),
|
||||
pin text, -- 6 dígitos, conferência humana
|
||||
valid_until timestamptz NOT NULL,
|
||||
used_at timestamptz,
|
||||
device_result text, -- resultado do AccessControlDevice (v2)
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Registro de que o aviso de tratamento foi exibido (LGPD: transparência, não consentimento)
|
||||
CREATE TABLE privacy_notices (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id uuid NOT NULL,
|
||||
visit_id uuid NOT NULL REFERENCES visits(id),
|
||||
notice_version text NOT NULL, -- versão do texto exibido
|
||||
shown_at timestamptz NOT NULL DEFAULT now(),
|
||||
ip_address inet,
|
||||
user_agent text
|
||||
);
|
||||
```
|
||||
|
||||
`privacy_notices` registra **exibição de aviso**, não consentimento — a base legal é legítimo interesse (ver `06-LGPD-E-SEGURANCA.md`). O que precisa ser provável é que a informação foi dada, com a versão exata do texto.
|
||||
|
||||
`sha256` em `media_assets` sustenta o valor probatório: sem hash, a foto guardada não prova nada em disputa jurídica.
|
||||
|
||||
## 7. Infraestrutura de confiabilidade
|
||||
|
||||
```sql
|
||||
-- Padrão 1: outbox transacional
|
||||
CREATE TABLE outbox_events (
|
||||
id bigserial PRIMARY KEY,
|
||||
tenant_id uuid NOT NULL,
|
||||
aggregate_type text NOT NULL,
|
||||
aggregate_id uuid NOT NULL,
|
||||
event_type text NOT NULL,
|
||||
payload jsonb NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
published_at timestamptz,
|
||||
attempts integer NOT NULL DEFAULT 0,
|
||||
next_attempt_at timestamptz NOT NULL DEFAULT now(),
|
||||
last_error text
|
||||
);
|
||||
CREATE INDEX idx_outbox_pending ON outbox_events (next_attempt_at)
|
||||
WHERE published_at IS NULL;
|
||||
|
||||
-- Padrões 2 e 6: timeouts persistidos e fila durável
|
||||
CREATE TABLE scheduled_jobs (
|
||||
id bigserial PRIMARY KEY,
|
||||
tenant_id uuid,
|
||||
job_type text NOT NULL, -- VISIT_RING_TIMEOUT | VISIT_ESCALATE | MEDIA_PURGE ...
|
||||
payload jsonb NOT NULL,
|
||||
run_at timestamptz NOT NULL,
|
||||
locked_until timestamptz,
|
||||
attempts integer NOT NULL DEFAULT 0,
|
||||
completed_at timestamptz,
|
||||
last_error text
|
||||
);
|
||||
CREATE INDEX idx_jobs_due ON scheduled_jobs (run_at)
|
||||
WHERE completed_at IS NULL;
|
||||
|
||||
-- Padrão 4: idempotência
|
||||
CREATE TABLE idempotency_keys (
|
||||
key text PRIMARY KEY,
|
||||
tenant_id uuid NOT NULL,
|
||||
endpoint text NOT NULL,
|
||||
request_hash text NOT NULL,
|
||||
response_code integer,
|
||||
response_body jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz NOT NULL DEFAULT now() + interval '24 hours'
|
||||
);
|
||||
```
|
||||
|
||||
Consumo da fila sempre com `FOR UPDATE SKIP LOCKED`, para que réplicas concorrentes não peguem o mesmo job:
|
||||
|
||||
```sql
|
||||
SELECT * FROM scheduled_jobs
|
||||
WHERE completed_at IS NULL AND run_at <= now()
|
||||
AND (locked_until IS NULL OR locked_until < now())
|
||||
ORDER BY run_at
|
||||
LIMIT 50
|
||||
FOR UPDATE SKIP LOCKED;
|
||||
```
|
||||
|
||||
## 8. Auditoria
|
||||
|
||||
```sql
|
||||
CREATE TABLE audit_log (
|
||||
id bigserial PRIMARY KEY,
|
||||
tenant_id uuid NOT NULL,
|
||||
actor_id uuid,
|
||||
actor_kind text NOT NULL, -- MORADOR | ADMIN | OPERADOR | VISITANTE | SISTEMA
|
||||
action text NOT NULL,
|
||||
entity_type text NOT NULL,
|
||||
entity_id uuid,
|
||||
before jsonb,
|
||||
after jsonb,
|
||||
ip_address inet,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX idx_audit_entity ON audit_log (tenant_id, entity_type, entity_id, created_at DESC);
|
||||
```
|
||||
|
||||
**Append-only.** Nenhum `UPDATE` ou `DELETE`, garantido por permissão do usuário de aplicação no banco. É a defesa jurídica em caso de autorização indevida.
|
||||
|
||||
## 9. Row Level Security — preparada, desativada
|
||||
|
||||
```sql
|
||||
CREATE POLICY tenant_isolation ON visits
|
||||
USING (tenant_id = current_setting('app.current_tenant', true)::uuid);
|
||||
ALTER TABLE visits DISABLE ROW LEVEL SECURITY; -- v1
|
||||
```
|
||||
|
||||
Criada em todas as tabelas de negócio na mesma migration da tabela. Ativar multi-tenancy vira `ENABLE ROW LEVEL SECURITY` + `SET app.current_tenant` por conexão.
|
||||
|
||||
## 10. Retenção e expurgo
|
||||
|
||||
| Dado | Retenção padrão | Mecanismo |
|
||||
|---|---|---|
|
||||
| Foto do visitante | 90 dias | `media_assets.expires_at` + job `MEDIA_PURGE` |
|
||||
| Foto de pacote | 30 dias | idem |
|
||||
| Recado em vídeo | 30 dias ou até o morador resolver | idem |
|
||||
| Gravação de chamada | 90 dias (só com módulo ativo) | idem |
|
||||
| Registro de visita (sem mídia) | 5 anos | retido — obrigação de segurança patrimonial |
|
||||
| `audit_log` | 5 anos | retido |
|
||||
| `idempotency_keys` | 24 horas | job de limpeza |
|
||||
|
||||
O job `MEDIA_PURGE` roda diariamente, apaga o objeto no MinIO e anula `storage_key`, **preservando a linha** — o registro de que existiu uma foto continua auditável mesmo depois de o dado pessoal ser eliminado.
|
||||
|
||||
## 11. Ordem das migrations
|
||||
|
||||
```
|
||||
V1__tenancy_e_planos.sql tenants, plans, tenant_features
|
||||
V2__estrutura_condominio.sql condominiums, blocks, units
|
||||
V3__pessoas_e_dispositivos.sql persons, unit_members, devices, operators
|
||||
V4__portarias.sql gates (+ PostGIS), gate_responsibles
|
||||
V5__visitas.sql visits, visit_attempts
|
||||
V6__midia_e_autorizacoes.sql media_assets, access_grants, privacy_notices
|
||||
V7__confiabilidade.sql outbox_events, scheduled_jobs, idempotency_keys
|
||||
V8__auditoria.sql audit_log + revogação de UPDATE/DELETE
|
||||
V9__rls_policies.sql policies criadas e desativadas
|
||||
V10__seed_planos.sql AUTONOMO e ASSISTIDO_24H
|
||||
```
|
||||
|
||||
`CREATE EXTENSION postgis;` e `pgcrypto` vão na `V1`.
|
||||
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)`.
|
||||
186
docs/04-DESIGN-SYSTEM-UX.md
Normal file
186
docs/04-DESIGN-SYSTEM-UX.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# 04 — Design system e UX
|
||||
|
||||
> Material 3 Expressive nos apps Compose; os mesmos tokens exportados como CSS custom properties nos webs. Uma linguagem visual, duas implementações.
|
||||
|
||||
## 1. Princípios
|
||||
|
||||
**1. O visitante está no sol, com pressa, e não vai instalar nada.** Cada tela dele tem uma ação. Tipografia grande, contraste alto, área de toque generosa.
|
||||
|
||||
**2. Espera precisa ter narração.** Um spinner de 35 segundos é indistinguível de um app quebrado. O visitante sempre lê o que está acontecendo: "Chamando o morador", "Tentando outros moradores", "Transferindo para a portaria".
|
||||
|
||||
**3. O morador decide em um toque.** A maior parte das decisões acontece na notificação, sem abrir o app.
|
||||
|
||||
**4. Densidade é para o admin, não para o campo.** Painel administrativo pode ter tabela densa e filtro complexo. Tela de portaria, nunca.
|
||||
|
||||
## 2. Tokens
|
||||
|
||||
Fonte única em `web/shared-ui/tokens.json`, gerada para os dois mundos no build:
|
||||
|
||||
```
|
||||
tokens.json ──► tokens.css (custom properties) → web/visitor, web/admin
|
||||
└─► Theme.kt (Material 3 Color) → apps Compose
|
||||
```
|
||||
|
||||
### Cor
|
||||
|
||||
```
|
||||
brand/primary #2563EB ações principais, foco
|
||||
brand/on-primary #FFFFFF
|
||||
semantic/success #15803D autorizado, dentro do geofence
|
||||
semantic/danger #B91C1C negado, expirado
|
||||
semantic/warning #B45309 fora do geofence, degradado
|
||||
semantic/info #0369A1 fila, estado transitório
|
||||
neutral/0..1000 escala de superfície e texto
|
||||
```
|
||||
|
||||
**Contraste mínimo 4.5:1 em tema claro e escuro** — verificado em CI, não no olho. A tela do visitante frequentemente é lida sob sol direto, onde o contraste efetivo despenca; por isso ela usa apenas os extremos da escala neutra, nunca tons médios.
|
||||
|
||||
Decisão nunca depende só de cor: autorizado é **verde + ícone de check + a palavra "Autorizado"**. Daltonismo e sol forte quebram codificação puramente cromática.
|
||||
|
||||
### Tipografia
|
||||
|
||||
`Inter` (web) / `Roboto Flex` (Compose), variáveis, com fallback de sistema.
|
||||
|
||||
| Papel | Tamanho | Uso |
|
||||
|---|---|---|
|
||||
| `display` | 32–40 | Estado da chamada na tela do visitante |
|
||||
| `headline` | 24–28 | Título de tela |
|
||||
| `title` | 18–20 | Nome do morador, unidade |
|
||||
| `body` | 16 | **Mínimo absoluto no fluxo do visitante** |
|
||||
| `label` | 14 | Rótulos de admin |
|
||||
| `caption` | 12 | Só metadados no admin. Nunca na portaria |
|
||||
|
||||
### Espaço, raio, elevação
|
||||
|
||||
Escala 4px (`0,1,2,3,4,6,8,12,16,24` × 4px). Raios: `sm 8` · `md 12` · `lg 16` · `full 999`. Elevação por sombra suave no web e `tonalElevation` no Compose — nunca sombra dura.
|
||||
|
||||
**Alvo de toque mínimo 48×48dp** em todo o fluxo do visitante, sem exceção. Mão trêmula, luva de entregador, celular escorregando.
|
||||
|
||||
## 3. Telas do visitante
|
||||
|
||||
Cinco telas, uma ação cada.
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Res. Bela Vista │ │ ┌───────────┐ │ │ Quem procura? │
|
||||
│ │ │ │ câmera │ │ │ │
|
||||
│ Este acesso │ │ │ frontal │ │ │ Seu nome │
|
||||
│ registra sua │ │ └───────────┘ │ │ [____________] │
|
||||
│ imagem e │ │ │ │ │
|
||||
│ localização │ │ Centralize o │ │ Bloco Unidade │
|
||||
│ para segurança. │ │ rosto │ │ [__] [_____] │
|
||||
│ │ │ │ │ │
|
||||
│ [Como funciona] │ │ ( ◎ tirar ) │ │ [ Continuar ] │
|
||||
│ [ Continuar ] │ │ │ │ │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
1. Aviso LGPD 3. Foto 4. Destino
|
||||
(2 = permissões, (após permissão) (busca cega)
|
||||
nativa do browser)
|
||||
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ ◐ │ │ ✓ │
|
||||
│ │ │ │
|
||||
│ Chamando o │ │ Autorizado │
|
||||
│ morador... │ │ │
|
||||
│ │ │ Mostre o código│
|
||||
│ ▓▓▓▓▓▓░░░░ 12s │ │ │
|
||||
│ │ │ 4 8 2 9 1 7 │
|
||||
│ Aguarde na │ │ │
|
||||
│ entrada │ │ válido 4:52 │
|
||||
│ │ │ │
|
||||
│ [ Cancelar ] │ │ [QR grande] │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
5. Espera narrada 6. Resultado
|
||||
```
|
||||
|
||||
**Tela 1** não é um modal de consentimento com checkbox. A base legal é legítimo interesse — o que se deve é **informar com clareza**, não colher aceite. Ver `06-LGPD-E-SEGURANCA.md`.
|
||||
|
||||
**Tela 5** troca o texto conforme o estado real (`TOCANDO` → `ESCALONADA` → `FILA_OPERADOR`), com barra de progresso ligada ao tempo restante. É a tela onde o produto se ganha ou se perde.
|
||||
|
||||
### Entrega — dois toques a menos
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ O que você faz │
|
||||
│ aqui hoje? │
|
||||
│ │
|
||||
│ ┌─────────────┐ │
|
||||
│ │ 📦 Entrega │ │ ← primeiro, é a maioria
|
||||
│ └─────────────┘ │
|
||||
│ ┌─────────────┐ │
|
||||
│ │ 👤 Visita │ │
|
||||
│ └─────────────┘ │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
Entrega pula a foto do rosto (fotografa o pacote) e vai direto ao destino. Meta de 10s do QR à resposta.
|
||||
|
||||
## 4. App do morador
|
||||
|
||||
### A notificação é a interface principal
|
||||
|
||||
```
|
||||
Android — CallStyle iOS — CallKit (tela cheia)
|
||||
┌──────────────────────────┐ ┌──────────────────────────┐
|
||||
│ 📦 Entrega · Apto 101-A │ │ Portaria │
|
||||
│ [foto do pacote] │ │ Entrega · Apto 101-A │
|
||||
│ │ │ │
|
||||
│ [Autorizar] [Chamar] │ │ [Recusar] [Aceitar] │
|
||||
└──────────────────────────┘ └──────────────────────────┘
|
||||
```
|
||||
|
||||
No iOS, chamada de **visita** usa PushKit + CallKit e ocupa a tela como ligação telefônica — o único caminho para tocar de verdade com o app fechado. **Entrega** usa notificação comum com ações, porque CallKit para uma entrega seria intrusivo e a Apple pode reprovar o uso.
|
||||
|
||||
### Tela de chamada
|
||||
|
||||
Vídeo do visitante em tela cheia; auto-preview pequeno no canto. Sobreposto: nome informado, unidade de destino, e **selo de geofence** — `✓ Na entrada` (verde) ou `⚠ A 340m da portaria` (âmbar). Esse selo é a informação de segurança mais útil da tela: alguém tentando entrar de longe é sinal claro.
|
||||
|
||||
Ações: `Autorizar` (verde, primária), `Negar` (contorno vermelho), `Mudo`, `Encerrar`.
|
||||
|
||||
## 5. App do operador
|
||||
|
||||
Densidade maior, feito para várias visitas em paralelo. Fila à esquerda com foto, unidade, tempo de espera e por que escalonou; contexto à direita antes de assumir — histórico da unidade, visitas recentes, regra de entrega. **O operador nunca atende sem contexto.**
|
||||
|
||||
## 6. Painel admin
|
||||
|
||||
Layout de aplicação: navegação lateral, conteúdo denso, filtros persistentes na URL.
|
||||
|
||||
A tela mais importante é a **auditoria de visitas**: tabela virtualizada, filtro por período/unidade/estado/tipo/geofence, e detalhe com foto, mapa (Leaflet) do ponto do visitante contra o raio do portão, timeline de tentativas (`visit_attempts`), e player do recado. Todo acesso a mídia gera linha em `audit_log` e usa URL assinada de 5 minutos.
|
||||
|
||||
## 7. Movimento
|
||||
|
||||
| Transição | Duração | Curva |
|
||||
|---|---|---|
|
||||
| Troca de tela do visitante | 280ms | `emphasized` |
|
||||
| Estado de chamada | 200ms | `standard` |
|
||||
| Entrada de card na fila | 180ms | `decelerate` |
|
||||
| Feedback de toque | 100ms | `linear` |
|
||||
|
||||
Compose usa `spring(dampingRatio = 0.8f)`; web usa `cubic-bezier(0.2, 0, 0, 1)`.
|
||||
|
||||
**`prefers-reduced-motion` respeitado em todos os webs** e `Settings.Global.ANIMATOR_DURATION_SCALE` no Android. Pulso e transição viram fade.
|
||||
|
||||
## 8. Acessibilidade
|
||||
|
||||
- Contraste ≥ 4.5:1 verificado em CI, claro e escuro
|
||||
- Alvos ≥ 48×48dp no fluxo do visitante
|
||||
- Rótulos semânticos em todo controle (`contentDescription` / `aria-label`)
|
||||
- Foco visível e ordem de tabulação correta no web
|
||||
- Suporte a fonte ampliada até 200% sem quebra de layout
|
||||
- Nenhuma informação transmitida só por cor
|
||||
- `lang="pt-BR"` e textos prontos para i18n desde o início (visitante estrangeiro é caso real)
|
||||
|
||||
## 9. Estados vazios, de erro e degradado
|
||||
|
||||
Todo erro exibido ao visitante vem do campo `detail` do `problem+json`, em português e acionável: **"Aproxime-se da entrada e tente novamente"**, nunca "OUTSIDE_GEOFENCE".
|
||||
|
||||
Modo degradado é comunicado, não escondido:
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ ⚠ Vídeo indisponível │
|
||||
│ Continuando por áudio. │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
Esconder a degradação faz o usuário achar que o produto está quebrado. Nomear a degradação faz o produto parecer resiliente — que é o que ele é.
|
||||
292
docs/05-INFRA-DOCKER.md
Normal file
292
docs/05-INFRA-DOCKER.md
Normal file
@@ -0,0 +1,292 @@
|
||||
# 05 — Infraestrutura e Docker
|
||||
|
||||
> Piloto: um VPS com Compose. Produção: mesmo código, só configuração diferente. O backend é stateless desde o dia 1, então migrar é config, não reescrita.
|
||||
|
||||
## 1. Topologia
|
||||
|
||||
```
|
||||
PILOTO (1 VPS — 8 vCPU, 16GB, link ≥ 1Gbps)
|
||||
traefik · backend · postgres · livekit · coturn · minio · observabilidade
|
||||
+ backup automático do Postgres com restore testado
|
||||
|
||||
PRODUÇÃO (mesmo compose, override de config)
|
||||
├─ Postgres gerenciado (Neon / RDS) com réplica e PITR
|
||||
├─ 2+ réplicas do backend atrás do Traefik
|
||||
├─ LiveKit + coturn em máquina de banda dedicada
|
||||
└─ MinIO → S3 (mesma API)
|
||||
```
|
||||
|
||||
**Por que o piloto num VPS só é aceitável:** o volume real é de ~0,3 req/s, e a falha é mitigada por backup testado e pela contingência física obrigatória (`07-REQUISITOS-DE-CAMPO.md`). O que **não** é aceitável é o código depender de rodar em instância única — daí a regra de ser stateless.
|
||||
|
||||
## 2. `infra/docker-compose.yml`
|
||||
|
||||
```yaml
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:v3.3
|
||||
command:
|
||||
- --providers.docker=true
|
||||
- --providers.docker.exposedbydefault=false
|
||||
- --entrypoints.web.address=:80
|
||||
- --entrypoints.websecure.address=:443
|
||||
- --entrypoints.web.http.redirections.entrypoint.to=websecure
|
||||
- --certificatesresolvers.le.acme.tlschallenge=true
|
||||
- --certificatesresolvers.le.acme.email=${ACME_EMAIL}
|
||||
- --certificatesresolvers.le.acme.storage=/acme/acme.json
|
||||
ports: ["80:80", "443:443"]
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- traefik_acme:/acme
|
||||
restart: unless-stopped
|
||||
|
||||
postgres:
|
||||
image: postgis/postgis:17-3.5
|
||||
environment:
|
||||
POSTGRES_DB: portaria
|
||||
POSTGRES_USER: portaria
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- ./postgres/init:/docker-entrypoint-initdb.d:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U portaria"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
backend:
|
||||
image: ghcr.io/${GH_OWNER}/portaria-backend:${TAG:-latest}
|
||||
environment:
|
||||
SPRING_PROFILES_ACTIVE: prod
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/portaria
|
||||
SPRING_DATASOURCE_USERNAME: portaria
|
||||
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
LIVEKIT_URL: ws://livekit:7880
|
||||
LIVEKIT_API_KEY: ${LIVEKIT_API_KEY}
|
||||
LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET}
|
||||
STORAGE_ENDPOINT: http://minio:9000
|
||||
STORAGE_ACCESS_KEY: ${MINIO_ROOT_USER}
|
||||
STORAGE_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
|
||||
FCM_CREDENTIALS_PATH: /secrets/fcm.json
|
||||
APNS_KEY_PATH: /secrets/apns.p8
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317
|
||||
volumes:
|
||||
- ./secrets:/secrets:ro
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
minio: { condition: service_started }
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost:8080/actuator/health/readiness"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.api.rule=Host(`api.${DOMAIN}`)
|
||||
- traefik.http.routers.api.tls.certresolver=le
|
||||
- traefik.http.services.api.loadbalancer.server.port=8080
|
||||
restart: unless-stopped
|
||||
|
||||
# SFU — host network é necessário: o LiveKit precisa das faixas UDP reais
|
||||
livekit:
|
||||
image: livekit/livekit-server:latest
|
||||
command: --config /etc/livekit.yaml
|
||||
network_mode: host
|
||||
volumes:
|
||||
- ./livekit/livekit.yaml:/etc/livekit.yaml:ro
|
||||
restart: unless-stopped
|
||||
|
||||
coturn:
|
||||
image: coturn/coturn:latest
|
||||
network_mode: host
|
||||
command: >
|
||||
-n --log-file=stdout --min-port=49160 --max-port=49200
|
||||
--realm=${DOMAIN} --use-auth-secret --static-auth-secret=${TURN_SECRET}
|
||||
--external-ip=${PUBLIC_IP} --no-cli --no-tlsv1 --no-tlsv1_1
|
||||
restart: unless-stopped
|
||||
|
||||
redis: # exclusivo do LiveKit e do rate limit
|
||||
image: redis:7-alpine # NUNCA para dados que não podem se perder
|
||||
command: redis-server --save "" --appendonly no
|
||||
restart: unless-stopped
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
|
||||
volumes:
|
||||
- miniodata:/data
|
||||
restart: unless-stopped
|
||||
|
||||
web-visitor:
|
||||
image: ghcr.io/${GH_OWNER}/portaria-web-visitor:${TAG:-latest}
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.visitor.rule=Host(`v.${DOMAIN}`)
|
||||
- traefik.http.routers.visitor.tls.certresolver=le
|
||||
restart: unless-stopped
|
||||
|
||||
web-admin:
|
||||
image: ghcr.io/${GH_OWNER}/portaria-web-admin:${TAG:-latest}
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.admin.rule=Host(`admin.${DOMAIN}`)
|
||||
- traefik.http.routers.admin.tls.certresolver=le
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
pgdata: {}
|
||||
miniodata: {}
|
||||
traefik_acme: {}
|
||||
```
|
||||
|
||||
Observabilidade (`otel-collector`, `prometheus`, `grafana`, `loki`) fica em `docker-compose.observability.yml`, ativada por perfil.
|
||||
|
||||
**Domínios:** `v.` (visitante) curto de propósito — ele aparece impresso no QR e às vezes é digitado à mão.
|
||||
|
||||
## 3. LiveKit e coturn
|
||||
|
||||
`infra/livekit/livekit.yaml`:
|
||||
|
||||
```yaml
|
||||
port: 7880
|
||||
rtc:
|
||||
tcp_port: 7881
|
||||
port_range_start: 50000
|
||||
port_range_end: 60000
|
||||
use_external_ip: true
|
||||
keys:
|
||||
${LIVEKIT_API_KEY}: ${LIVEKIT_API_SECRET}
|
||||
redis:
|
||||
address: localhost:6379 # obrigatório com mais de uma instância
|
||||
turn:
|
||||
enabled: false # coturn cuida do TURN
|
||||
room:
|
||||
empty_timeout: 120
|
||||
max_participants: 6
|
||||
```
|
||||
|
||||
### Portas — abrir no firewall
|
||||
|
||||
| Porta | Protocolo | Serviço |
|
||||
|---|---|---|
|
||||
| 80, 443 | TCP | Traefik |
|
||||
| 7880 | TCP | LiveKit signaling (atrás do Traefik) |
|
||||
| 7881 | TCP | LiveKit ICE/TCP (fallback) |
|
||||
| 50000–60000 | UDP | Mídia WebRTC |
|
||||
| 3478 | UDP/TCP | STUN/TURN |
|
||||
| 49160–49200 | UDP | Relay do coturn |
|
||||
|
||||
**Erro clássico:** esquecer a faixa UDP. A chamada conecta, a sinalização funciona, e o vídeo nunca aparece — sintoma que parece bug de aplicação e é firewall.
|
||||
|
||||
**Redis não é opcional** com múltiplas instâncias do LiveKit: sem ele, o estado das salas se divide silenciosamente entre nós e dois participantes da mesma sala não se enxergam.
|
||||
|
||||
## 4. Dimensionamento de banda
|
||||
|
||||
Uma chamada de portaria tem 2 participantes, vídeo em 480p a ~600kbps mais áudio. O SFU recebe e reenvia:
|
||||
|
||||
```
|
||||
1 chamada ≈ 1,2 Mbps entrada + 1,2 Mbps saída ≈ 2,4 Mbps
|
||||
```
|
||||
|
||||
| Chamadas simultâneas | Banda no SFU |
|
||||
|---|---|
|
||||
| 10 | ~24 Mbps |
|
||||
| 50 | ~120 Mbps |
|
||||
| 100 | ~240 Mbps |
|
||||
| 200 | ~480 Mbps |
|
||||
|
||||
Chamadas de portaria duram 30–60s. Um condomínio de 200 unidades gera ~50 visitas/dia com pico de 3–5 simultâneas. **Um VPS de 1Gbps atende com folga dezenas de condomínios.**
|
||||
|
||||
O que estoura banda é gravação (módulo `video_recording`): o LiveKit Egress soma outro fluxo por chamada e escreve no MinIO. Ative com quota por plano.
|
||||
|
||||
**Limite de resolução no servidor**, não no cliente: 480p é suficiente para reconhecer alguém na portaria, e 1080p multiplicaria o custo por quatro sem ganho de decisão.
|
||||
|
||||
## 5. Variáveis de ambiente
|
||||
|
||||
`infra/.env.example` (nunca commitar `.env`):
|
||||
|
||||
```bash
|
||||
DOMAIN=portaria.exemplo.com.br
|
||||
PUBLIC_IP=203.0.113.10
|
||||
ACME_EMAIL=ops@exemplo.com.br
|
||||
GH_OWNER=sua-org
|
||||
TAG=latest
|
||||
|
||||
POSTGRES_PASSWORD=
|
||||
LIVEKIT_API_KEY=
|
||||
LIVEKIT_API_SECRET=
|
||||
TURN_SECRET=
|
||||
MINIO_ROOT_USER=
|
||||
MINIO_ROOT_PASSWORD=
|
||||
JWT_SIGNING_KEY=
|
||||
```
|
||||
|
||||
Credenciais de FCM e APNs são arquivos em `./secrets/`, montados read-only. **Nada de secret em imagem.**
|
||||
|
||||
## 6. Observabilidade
|
||||
|
||||
Actuator expõe `/actuator/health` (liveness e readiness), `/actuator/prometheus` e `/actuator/info`. OpenTelemetry exporta traces do backend, do LiveKit e do Postgres.
|
||||
|
||||
**Dashboards obrigatórios no Grafana:**
|
||||
|
||||
1. **SLOs** — latência QR→toque (p50/p95/p99), taxa de atendimento, latência de entrega, abandono
|
||||
2. **Fila e outbox** — profundidade de `scheduled_jobs`, idade do evento mais antigo não publicado, taxa de retry
|
||||
3. **Notificações** — push enviados, entregues e falhados por plataforma; **tokens inválidos** (indicador precoce de morador que desinstalou)
|
||||
4. **Mídia** — chamadas ativas, banda, falhas de conexão, uso do TURN
|
||||
|
||||
**Alertas:**
|
||||
|
||||
| Alerta | Condição | Severidade |
|
||||
|---|---|---|
|
||||
| Outbox atrasado | evento não publicado > 60s | crítico |
|
||||
| Fila travada | job vencido > 30s | crítico |
|
||||
| Push falhando | taxa de falha > 20% em 5min | crítico |
|
||||
| LiveKit indisponível | circuit breaker aberto | crítico |
|
||||
| Taxa de atendimento baixa | < 50% num condomínio por 7 dias | negócio |
|
||||
| Disco do MinIO | > 80% | aviso |
|
||||
|
||||
Os dois últimos são de negócio, não de infraestrutura — viram tarefa de customer success, não plantão.
|
||||
|
||||
## 7. Backup e restore
|
||||
|
||||
```
|
||||
Postgres pg_dump diário + WAL archiving retenção 30 dias
|
||||
MinIO replicação de bucket ou snapshot diário retenção 30 dias
|
||||
Segredos fora do servidor, em cofre
|
||||
```
|
||||
|
||||
**Restore é testado mensalmente em ambiente separado, com o tempo medido.** Backup não verificado não é backup — e aqui perder o banco significa não só perder dados, mas deixar prédios inacessíveis.
|
||||
|
||||
## 8. CI/CD
|
||||
|
||||
```
|
||||
push → GitHub Actions
|
||||
├─ backend: ./gradlew build (unit + Testcontainers)
|
||||
├─ shared: ./gradlew allTests (JVM + iOS)
|
||||
├─ web: pnpm lint && pnpm test && pnpm build
|
||||
│ └─ contraste + orçamento de bundle (falha se visitor > 200KB gzip)
|
||||
├─ docker compose config (valida o compose)
|
||||
└─ tag na main → build/push das imagens → deploy por SSH
|
||||
```
|
||||
|
||||
O **orçamento de bundle do visitante é gate de CI**, não recomendação. A escolha de React em vez de Wasm foi motivada por TTI; sem gate automático, essa vantagem evapora em três sprints.
|
||||
|
||||
## 9. Modo degradado
|
||||
|
||||
Quando o circuit breaker do LiveKit abre, o backend rebaixa automaticamente e emite `DEGRADED_MODE` no WebSocket:
|
||||
|
||||
```
|
||||
LiveKit fora ─► modo AUDIO
|
||||
└─► modo FOTO_TEXTO (visitante manda foto, morador aprova)
|
||||
└─► FILA_OPERADOR (resolução por telefone)
|
||||
|
||||
Backend fora ─► contingência física (botão + telefone do responsável)
|
||||
documentada em 07-REQUISITOS-DE-CAMPO.md
|
||||
```
|
||||
|
||||
O nível vigente aparece em `/actuator/health` e no painel admin. **Degradar em silêncio é pior que degradar** — o síndico precisa saber que o sistema está em modo reduzido antes de receber a reclamação.
|
||||
196
docs/06-LGPD-E-SEGURANCA.md
Normal file
196
docs/06-LGPD-E-SEGURANCA.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# 06 — LGPD e segurança
|
||||
|
||||
> Este documento tem consequência jurídica real. O condomínio é controlador dos dados; a plataforma é operadora. Um erro de base legal aqui não é bug — é multa e responsabilidade civil.
|
||||
> Não substitui parecer jurídico. Antes de operar comercialmente, valide com advogado especializado.
|
||||
|
||||
## 1. A base legal — e por que a intuição está errada
|
||||
|
||||
A intuição diz: "peça consentimento ao visitante". **Está errada.**
|
||||
|
||||
Consentimento na LGPD precisa ser **livre** (art. 5º, XII). Um visitante que só entra no prédio se aceitar ser fotografado não está consentindo livremente — está sob condição. Consentimento obtido assim é frágil e pode ser considerado inválido, derrubando toda a base de tratamento.
|
||||
|
||||
Além disso, o titular pode revogar o consentimento a qualquer momento (art. 8º, §5º). Um sistema de segurança cuja base legal evapora quando o visitante pede não serve como sistema de segurança.
|
||||
|
||||
**Base legal correta: legítimo interesse** — art. 7º, IX, para segurança patrimonial e das pessoas do condomínio. Em situações de risco à integridade física, o art. 7º, VII também sustenta.
|
||||
|
||||
| Dado | Base legal | Finalidade |
|
||||
|---|---|---|
|
||||
| Nome do visitante | Legítimo interesse (7º, IX) | Identificação de quem acessa |
|
||||
| Foto do rosto | Legítimo interesse (7º, IX) | Validação visual pelo morador |
|
||||
| Geolocalização | Legítimo interesse (7º, IX) | Anti-fraude: confirmar presença física na portaria |
|
||||
| Documento (opcional) | Legítimo interesse (7º, IX) | Identificação em ocorrência |
|
||||
| Gravação de chamada | Legítimo interesse + **aviso reforçado** | Prova em disputa (módulo opcional) |
|
||||
| Dados do morador | Execução de contrato (7º, V) | Prestação do serviço |
|
||||
|
||||
### A LIA é obrigatória
|
||||
|
||||
Legítimo interesse exige **Legitimate Interest Assessment** documentada e arquivada, com três etapas:
|
||||
|
||||
1. **Finalidade legítima** — controle de acesso e segurança patrimonial são interesse legítimo pacífico.
|
||||
2. **Necessidade** — o dado é o mínimo? Foto do rosto: sim, é como o morador valida quem está lá fora. Geolocalização: sim, é o que impede acionamento remoto fraudulento. **Documento: não é necessário** — por isso é opcional e não bloqueia o fluxo.
|
||||
3. **Balanceamento** — o direito do titular prevalece? Aqui entram as salvaguardas: retenção curta, acesso restrito e auditado, sem uso secundário, sem compartilhamento, sem biometria.
|
||||
|
||||
Modelo de LIA em `docs/anexos/LIA-modelo.md`, a ser preenchido **por condomínio** (cada um é controlador dos seus dados).
|
||||
|
||||
### O que muda na prática
|
||||
|
||||
Não existe tela de "Aceito os termos" com checkbox. Existe **aviso de tratamento** claro, exibido antes da coleta:
|
||||
|
||||
> **Este acesso é registrado.** Sua imagem e localização são coletadas para segurança do condomínio, ficam guardadas por 90 dias e são acessíveis apenas ao morador que você procura e à administração. Base legal: legítimo interesse (LGPD, art. 7º, IX). [Saiba mais]
|
||||
|
||||
O que se registra em `privacy_notices` é a **exibição do aviso** — versão do texto, timestamp, IP, user agent. O que precisa ser provável é que a informação foi dada.
|
||||
|
||||
E uma placa física na portaria com o mesmo aviso, para quem entra sem usar o sistema.
|
||||
|
||||
## 2. Por que não há reconhecimento facial
|
||||
|
||||
Dado biométrico é **dado pessoal sensível** (art. 5º, II). Tratamento de dado sensível tem lista fechada de hipóteses (art. 11) e **não admite legítimo interesse**. Restaria consentimento — que, como visto, não é livre nesse contexto.
|
||||
|
||||
Ou seja: reconhecimento facial em portaria de visitante fica sem base legal sólida. É por isso que está fora do escopo — decisão jurídica, não limitação técnica.
|
||||
|
||||
**Foto ≠ biometria.** Uma foto guardada e vista por um humano é dado pessoal comum. Ela vira biométrica quando é processada para extrair template facial e identificar automaticamente. A fronteira é o processamento, não a imagem.
|
||||
|
||||
**Regra de engenharia:** nenhuma biblioteca de detecção ou extração facial entra no projeto, nem "só para enquadrar o rosto". A tentação aparece disfarçada de UX.
|
||||
|
||||
## 3. Direitos do titular
|
||||
|
||||
| Direito | Implementação | Prazo |
|
||||
|---|---|---|
|
||||
| Confirmação e acesso | `POST /admin/lgpd/export` por CPF ou telefone | 15 dias |
|
||||
| Correção | Admin edita e registra em `audit_log` | 15 dias |
|
||||
| Eliminação | `POST /admin/lgpd/erase` — apaga mídia, anonimiza nome | 15 dias |
|
||||
| Portabilidade | Exportação em JSON | 15 dias |
|
||||
| Informação sobre compartilhamento | Documentada no aviso | imediato |
|
||||
| Oposição | Registrada; avaliada contra o legítimo interesse | 15 dias |
|
||||
|
||||
**Eliminação não apaga a linha da visita.** Apaga o dado pessoal (mídia, nome, documento) e mantém o registro anonimizado com data, unidade e resultado. Justificativa: obrigação de guarda de registro de acesso para segurança patrimonial — a linha vira estatística, não identificação.
|
||||
|
||||
O condomínio é quem responde ao titular; a plataforma fornece a ferramenta. Isso precisa estar no contrato de operador.
|
||||
|
||||
## 4. Retenção
|
||||
|
||||
| Dado | Prazo | Fundamento |
|
||||
|---|---|---|
|
||||
| Foto do visitante | 90 dias | Janela típica para descoberta de ocorrência |
|
||||
| Foto de pacote | 30 dias | Extravio se descobre rápido |
|
||||
| Recado em vídeo | 30 dias, ou até resolvido | Finalidade se esgota |
|
||||
| Gravação de chamada | 90 dias | Só com módulo ativo e aviso reforçado |
|
||||
| Registro da visita (sem mídia) | 5 anos | Segurança patrimonial |
|
||||
| `audit_log` | 5 anos | Prova de conformidade |
|
||||
|
||||
Job `MEDIA_PURGE` diário: apaga o objeto no MinIO, anula `storage_key`, **preserva a linha** em `media_assets`. Fica provado que existiu uma foto e que ela foi eliminada no prazo — que é exatamente o que se precisa demonstrar numa fiscalização.
|
||||
|
||||
Prazos são configuráveis por tenant, com **teto** definido pela plataforma. Um condomínio querendo guardar foto por 5 anos configuraria um risco que a plataforma não aceita hospedar.
|
||||
|
||||
## 5. Modelo de ameaças
|
||||
|
||||
### A.1 — Enumeração de moradores (crítica)
|
||||
|
||||
**Ataque:** com o QR (visível na entrada, fotografável da calçada), alguém testa combinações de bloco e unidade e descobre quais existem e quem mora onde. É reconhecimento para assalto ou stalking — segurança física antes de privacidade.
|
||||
|
||||
**Mitigação — busca cega.** A API **nunca** revela se uma unidade existe:
|
||||
|
||||
- Visita criada mesmo com `unit_id` nulo, preservando `unit_input`
|
||||
- Resposta idêntica em corpo, código e headers
|
||||
- **Tempo de resposta constante** (delay artificial iguala os caminhos — sem isso o ataque vira timing attack)
|
||||
- Nome do morador nunca aparece antes do atendimento
|
||||
- Rate limit por IP e por dispositivo, com bloqueio progressivo
|
||||
|
||||
**Regra de implementação:** a tela do visitante nunca exibe autocomplete, sugestão ou validação de unidade. O campo é livre.
|
||||
|
||||
### A.2 — Acionamento remoto fraudulento
|
||||
|
||||
**Ataque:** foto do QR, acionamento de casa, engenharia social por vídeo ("sou da manutenção").
|
||||
|
||||
**Mitigação:** geofence `ST_DWithin` contra `gates.location`; fora do raio, `403 OUTSIDE_GEOFENCE`. Localização negada bloqueia o fluxo. O selo de distância aparece na tela do morador durante a chamada — `⚠ A 340m da portaria` é o sinal mais claro possível.
|
||||
|
||||
**Limitação assumida:** GPS é falsificável com app rooteado. A defesa é dissuasória, não absoluta; complementada por rate limit e auditoria. Um atacante determinado passa — mas a tentativa fica registrada com foto.
|
||||
|
||||
### A.3 — DoS social
|
||||
|
||||
**Ataque:** tocar em todos os apartamentos de madrugada.
|
||||
|
||||
**Mitigação:** `condominiums.quiet_hours` — na janela de silêncio, visitas não pré-autorizadas vão direto para operador ou recado, sem acordar moradores. Rate limit por dispositivo, bloqueio progressivo, e alerta no admin ao detectar padrão de varredura.
|
||||
|
||||
### A.4 — Vazamento de mídia
|
||||
|
||||
**Mitigação:** bucket privado, **nenhuma URL pública**; acesso só por URL assinada de 5 minutos gerada sob autenticação; todo acesso registrado em `audit_log`; criptografia em repouso; `sha256` de cada objeto como prova de integridade.
|
||||
|
||||
### A.5 — Adulteração de auditoria
|
||||
|
||||
**Ataque:** síndico envolvido em ocorrência altera ou apaga registros.
|
||||
|
||||
**Mitigação:** `audit_log` é append-only, com `UPDATE` e `DELETE` **revogados no banco** para o usuário da aplicação:
|
||||
|
||||
```sql
|
||||
REVOKE UPDATE, DELETE ON audit_log FROM portaria_app;
|
||||
```
|
||||
|
||||
Nem a aplicação comprometida altera o log. Somado ao `sha256` da mídia, isso é o que dá valor probatório ao registro.
|
||||
|
||||
### A.6 — Roubo de token
|
||||
|
||||
**Mitigação:** JWT de visitante com 15 min, escopo de uma visita, atado ao `gateId` e ao IP de origem. Refresh do morador no Keystore/Keychain com biometria de dispositivo. Rotação de refresh token e revogação por dispositivo no admin.
|
||||
|
||||
### A.7 — QR vazado ou clonado
|
||||
|
||||
**Mitigação:** `gates.qr_version` — incrementar invalida todos os QRs anteriores. Reimprime a placa e os códigos antigos param de validar. Não é rotação automática (o QR é impresso), mas é resposta a incidente.
|
||||
|
||||
## 6. Autenticação e autorização
|
||||
|
||||
| Perfil | Mecanismo | Sessão |
|
||||
|---|---|---|
|
||||
| Visitante | JWT efêmero pós-QR + geofence | 15 min, uma visita |
|
||||
| Morador | OIDC + refresh no keystore | 15 min / 30 dias |
|
||||
| Operador | OIDC + refresh | 15 min / 30 dias |
|
||||
| Admin | OIDC + **MFA obrigatório** | 8h |
|
||||
|
||||
MFA no admin não é opcional: esse perfil vê fotos de todos os visitantes e a localização de todos os acessos. É o alvo mais valioso do sistema.
|
||||
|
||||
**Autorização** com Spring Security por método, sempre com `tenant_id` no predicado:
|
||||
|
||||
```kotlin
|
||||
@PreAuthorize("@access.canViewVisit(#visitId, authentication)")
|
||||
fun getVisit(visitId: UUID): VisitDto
|
||||
```
|
||||
|
||||
Morador vê apenas visitas das suas unidades. Operador vê apenas as que estão ou estiveram na fila. Admin vê todas do seu tenant — e cada acesso a mídia gera linha de auditoria.
|
||||
|
||||
## 7. Segurança da aplicação
|
||||
|
||||
Headers no Traefik: HSTS, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, CSP restritiva com `frame-ancestors 'none'`.
|
||||
|
||||
Upload de mídia: `Content-Type` validado por **magic bytes**, não por extensão; limite de 8MB para foto e 30MB para vídeo; metadados EXIF removidos no servidor (**EXIF carrega GPS próprio, que não é o dado que queremos e não passou pelo aviso**); nome de arquivo gerado pelo servidor, nunca o do cliente.
|
||||
|
||||
Rate limits: sessão de visitante 5/min por IP e 20/h por dispositivo; criação de visita 3/min por sessão; login admin 5/15min com bloqueio progressivo.
|
||||
|
||||
Dependências: Dependabot ativo, `gradle dependencyCheck` e `pnpm audit` em CI, build falha em vulnerabilidade alta ou crítica.
|
||||
|
||||
## 8. Contratos e papéis
|
||||
|
||||
**Condomínio = controlador. Plataforma = operadora.** O contrato de operador precisa fixar: finalidades permitidas, proibição de uso secundário, prazos de retenção, obrigação de auxílio no atendimento a titulares, notificação de incidente em até 24h, e destino dos dados no encerramento.
|
||||
|
||||
Cada condomínio indica um **encarregado (DPO)**, cadastrado no admin e exibido no aviso de privacidade.
|
||||
|
||||
**Incidente de segurança:** contenção → registro → avaliação de risco → comunicação à ANPD e aos titulares em prazo razoável (a ANPD orienta 2 dias úteis) → relatório final. Runbook em `docs/anexos/runbook-incidente.md`.
|
||||
|
||||
## 9. Checklist de conformidade
|
||||
|
||||
- [ ] LIA preenchida e arquivada por condomínio
|
||||
- [ ] Aviso de tratamento visível antes de qualquer coleta, com versão registrada
|
||||
- [ ] Placa física na portaria
|
||||
- [ ] Encarregado indicado e publicado
|
||||
- [ ] Retenção configurada e job de expurgo rodando
|
||||
- [ ] Exportação e eliminação testadas ponta a ponta
|
||||
- [ ] `audit_log` com `UPDATE`/`DELETE` revogados no banco
|
||||
- [ ] Nenhuma URL pública de mídia
|
||||
- [ ] Nenhuma biblioteca de reconhecimento facial no `build.gradle.kts` ou no `package.json`
|
||||
- [ ] Busca cega verificada — inclusive tempo de resposta constante
|
||||
- [ ] Contrato de operador assinado
|
||||
- [ ] Runbook de incidente testado
|
||||
|
||||
## 10. Referências
|
||||
|
||||
- [Insoft4 — controle de visitantes sob a LGPD](https://www.insoft4.com.br/blog/controle-de-acesso-dos-visitantes-de-acordo-com-a-lgpd)
|
||||
- [ConJur — reconhecimento facial em condomínios](https://www.conjur.com.br/2024-abr-07/reconhecimento-facial-em-condominios-desafios-sob-a-otica-da-lgpd/)
|
||||
- [ConJur — LGPD e condomínios](https://www.conjur.com.br/2022-dez-17/william-rocha-lgpd-condominios/)
|
||||
150
docs/07-REQUISITOS-DE-CAMPO.md
Normal file
150
docs/07-REQUISITOS-DE-CAMPO.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# 07 — Requisitos de campo
|
||||
|
||||
> Este documento não é backlog de software. É **pré-requisito contratual de instalação**. Um condomínio que não atenda a estes itens não pode ser ativado — o produto vai falhar em campo por motivos que nenhuma linha de código resolve.
|
||||
|
||||
## 1. Por que este documento existe
|
||||
|
||||
O produto inteiro pressupõe que o visitante consegue abrir uma página web na calçada. Essa premissa quebra com frequência:
|
||||
|
||||
- **Hall de entrada blindado não pega 4G.** Concreto armado, vidro laminado e subsolo derrubam o sinal. O visitante escaneia o QR e nada acontece.
|
||||
- **Celular sem bateria** no fim do dia de um entregador.
|
||||
- **Visitante sem smartphone** — idoso entregando documento, prestador com aparelho antigo.
|
||||
- **Sol direto** tornando o QR ilegível e a tela invisível.
|
||||
|
||||
Nenhum desses é resolvível em software. São requisitos físicos, e precisam estar no contrato de instalação junto com o preço.
|
||||
|
||||
## 2. Checklist de ativação
|
||||
|
||||
Um condomínio só entra em produção com todos os itens verificados **em campo**, não no papel.
|
||||
|
||||
### 2.1 Conectividade — obrigatório
|
||||
|
||||
- [ ] **Wi-Fi aberto de visitante** com cobertura medida em cada portaria
|
||||
- [ ] SSID óbvio: `Portaria-<NomeDoCondominio>`
|
||||
- [ ] **Captive portal** que abre direto a página do visitante
|
||||
- [ ] Rede **isolada** da rede administrativa (VLAN separada, sem acesso à LAN interna)
|
||||
- [ ] Banda mínima 5 Mbps simétricos reservados para o visitante
|
||||
- [ ] Teste de sinal ≥ -70 dBm no ponto exato onde o visitante fica
|
||||
|
||||
A rede de visitante isolada não é detalhe: uma rede aberta com acesso à LAN do condomínio é porta de entrada para ataque. Ela serve **apenas** para chegar à internet.
|
||||
|
||||
### 2.2 QR — obrigatório
|
||||
|
||||
- [ ] Impresso em material **fosco** (brilhante reflete sol e não é lido)
|
||||
- [ ] Mínimo **15×15 cm**, correção de erro nível H
|
||||
- [ ] Altura de 1,20 a 1,50 m do chão
|
||||
- [ ] Protegido de sol direto (marquise, ângulo, ou toldo)
|
||||
- [ ] URL curta legível abaixo do código, para digitação manual
|
||||
- [ ] Instrução em português e inglês
|
||||
- [ ] Suporte com placa reserva, para reimpressão em caso de vandalismo
|
||||
|
||||
### 2.3 Fallback físico — obrigatório
|
||||
|
||||
- [ ] **Botão físico de chamada** na portaria, ligado ao mesmo fluxo
|
||||
- [ ] Botão à altura acessível (≤ 1,20 m) e identificado com pictograma
|
||||
- [ ] Aciona chamada para a portaria/operador **sem depender de celular**
|
||||
- [ ] Alimentação com no-break de no mínimo 4 horas
|
||||
|
||||
O botão físico é o que atende quem não tem celular, está sem bateria ou não consegue usar o QR. **Sem ele, o produto exclui uma parcela real de visitantes** — e cria a situação em que alguém legítimo simplesmente não consegue entrar.
|
||||
|
||||
### 2.4 Aviso legal — obrigatório
|
||||
|
||||
- [ ] **Placa física** com o aviso de tratamento de dados, visível antes da coleta
|
||||
- [ ] Nome e contato do encarregado (DPO) do condomínio
|
||||
- [ ] Texto idêntico ao exibido na tela, com a mesma versão
|
||||
- [ ] LIA preenchida e arquivada (`06-LGPD-E-SEGURANCA.md`)
|
||||
|
||||
### 2.5 Energia e contingência — obrigatório
|
||||
|
||||
- [ ] No-break para roteador, botão de chamada e equipamento de rede (≥ 4h)
|
||||
- [ ] **Telefone de contingência** do responsável pela abertura, afixado na portaria
|
||||
- [ ] Procedimento escrito para queda total, conhecido pelo zelador
|
||||
- [ ] Contato do síndico e da empresa de segurança visíveis
|
||||
|
||||
### 2.6 Cadastro — obrigatório
|
||||
|
||||
- [ ] Base de unidades importada e conferida contra a lista da administradora
|
||||
- [ ] Ao menos **um morador com app instalado por unidade** (meta: 80% das unidades)
|
||||
- [ ] `ring_order` definido nas unidades com múltiplos moradores
|
||||
- [ ] `delivery_rule` definida por unidade (default `DEIXAR_PORTARIA`)
|
||||
- [ ] Responsáveis pela abertura cadastrados com prioridade
|
||||
- [ ] `quiet_hours` acordada com o síndico
|
||||
- [ ] `geofence_meters` calibrado no local (default 80 m; ajustar em condomínio grande)
|
||||
|
||||
**Unidade sem nenhum morador com app é unidade que nunca atende.** Abaixo de 60% de cobertura, o condomínio não deve ser ativado no plano Autônomo — só no Assistido.
|
||||
|
||||
## 3. Calibragem do geofence
|
||||
|
||||
O raio padrão de 80 m funciona na maioria dos casos, mas precisa ser medido:
|
||||
|
||||
1. Ficar no ponto do visitante e registrar a coordenada em `gates.location`
|
||||
2. Medir a precisão real do GPS no local (prédio alto e marquise degradam muito)
|
||||
3. Ajustar `geofence_meters` para cobrir a imprecisão sem abrir demais
|
||||
4. Testar com dois aparelhos diferentes, Android e iPhone
|
||||
|
||||
**Raio apertado demais rejeita visitante legítimo** — falha muito pior que aceitar alguém a 100 m. Na dúvida, use raio maior e confie no selo de distância que aparece para o morador durante a chamada.
|
||||
|
||||
## 4. Onboarding do condomínio
|
||||
|
||||
```
|
||||
1. Visita técnica sinal, energia, posição do QR, ponto do botão 1 dia
|
||||
2. Contrato plano, operador, LIA, contrato de operador —
|
||||
3. Infraestrutura Wi-Fi, captive portal, botão, no-break, placas 2–5 dias
|
||||
4. Cadastro import CSV, conferência, responsáveis 1 dia
|
||||
5. Adesão dos moradores convites, assembleia, suporte na instalação 2–4 semanas
|
||||
6. Piloto assistido operador ligado, monitoramento diário 2 semanas
|
||||
7. Ativação plano definitivo conforme taxa de atendimento —
|
||||
```
|
||||
|
||||
**A etapa 5 é a mais longa e a mais subestimada.** Adesão dos moradores é trabalho de campo, não de software: assembleia, cartaz no elevador, plantão de instalação no hall. Um condomínio com 30% de adesão não tem produto funcionando, por melhor que o código esteja.
|
||||
|
||||
**A etapa 6 não é opcional.** Duas semanas com operador ligado revelam a taxa real de atendimento daquele condomínio. É esse número — não a expectativa comercial — que define se ele fica no plano Autônomo ou no Assistido.
|
||||
|
||||
## 5. Reconciliação contínua
|
||||
|
||||
A base de moradores desatualiza sozinha: gente muda de apartamento, vende, aluga, troca de celular. Base desatualizada quebra o produto **silenciosamente** — a chamada simplesmente não é atendida e ninguém sabe por quê.
|
||||
|
||||
**Mensal**, o relatório `GET /admin/reconciliation` lista:
|
||||
|
||||
- Moradores com `valid_until` vencido
|
||||
- Unidades sem nenhum dispositivo ativo
|
||||
- Dispositivos com push falhando há mais de 7 dias (**token morto = desinstalou**)
|
||||
- Unidades com taxa de atendimento abaixo de 30% no mês
|
||||
- Moradores convidados que nunca aceitaram
|
||||
|
||||
O síndico ou a administradora confirma as mudanças. **Isso é rotina de operação, não incidente** — e é o que mantém o produto vivo depois do primeiro mês.
|
||||
|
||||
## 6. Procedimento de contingência
|
||||
|
||||
Afixado fisicamente na portaria:
|
||||
|
||||
```
|
||||
SE O SISTEMA NÃO RESPONDER
|
||||
|
||||
1. Use o botão de chamada da portaria
|
||||
2. Se não funcionar, ligue para o responsável:
|
||||
<NOME> <TELEFONE>
|
||||
3. Fora do horário, segurança 24h:
|
||||
<TELEFONE>
|
||||
4. Registre a entrada no livro físico
|
||||
|
||||
O livro físico permanece na portaria e é preenchido
|
||||
sempre que o sistema estiver indisponível.
|
||||
```
|
||||
|
||||
**O livro físico não é retrocesso — é a contingência que torna o produto vendável.** Enquanto a abertura depender de humano e existir queda de energia, o condomínio precisa de um caminho que funcione sem eletricidade e sem internet. Vender autonomia total seria desonesto, e a primeira queda destruiria a confiança no produto.
|
||||
|
||||
## 7. Materiais de campo
|
||||
|
||||
Entregues na ativação:
|
||||
|
||||
| Material | Onde |
|
||||
|---|---|
|
||||
| Placa do QR (fosca, 15×15) | Cada portaria |
|
||||
| Placa do aviso LGPD + DPO | Ao lado do QR |
|
||||
| Adesivo do botão de chamada | Junto ao botão |
|
||||
| Cartaz de contingência | Interior da portaria |
|
||||
| Cartaz de adesão para moradores | Elevadores e hall |
|
||||
| Guia rápido do morador (1 página) | Digital + impresso |
|
||||
| Guia do responsável pela abertura | Impresso na portaria |
|
||||
| Guia do síndico (admin) | Digital |
|
||||
79
prompts/FASE-0-bootstrap.md
Normal file
79
prompts/FASE-0-bootstrap.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# FASE 0 — Bootstrap do monorepo
|
||||
|
||||
## Objetivo
|
||||
|
||||
Estrutura completa do monorepo, ambiente Docker subindo, CI verde. Nenhuma regra de negócio ainda — apenas o esqueleto onde tudo será construído.
|
||||
|
||||
## Pré-requisitos
|
||||
|
||||
Ler `docs/01-ARQUITETURA.md` (§3 estrutura do monorepo) e `docs/05-INFRA-DOCKER.md` (compose completo).
|
||||
|
||||
## Tarefas
|
||||
|
||||
### 1. Raiz do Gradle
|
||||
|
||||
`settings.gradle.kts` incluindo `:shared` e `:backend`. Version catalog em `gradle/libs.versions.toml` com: Kotlin 2.1+, Spring Boot 3.4+, jOOQ, Flyway, PostGIS driver, Testcontainers, ShedLock, Resilience4j, LiveKit server SDK, kotlinx.serialization, kotlinx.datetime.
|
||||
|
||||
JDK 21. Toolchain configurado explicitamente.
|
||||
|
||||
### 2. Módulo `shared/`
|
||||
|
||||
KMP com targets `jvm()`, `androidTarget()`, `iosArm64()`, `iosSimulatorArm64()`. Pacote base `br.com.portaria.shared`. Apenas kotlinx.serialization e kotlinx.datetime como dependências — **este módulo não conhece Spring, Android nem iOS**.
|
||||
|
||||
Pacotes vazios criados: `model/`, `dto/`, `state/`, `validation/`.
|
||||
|
||||
### 3. Módulo `backend/`
|
||||
|
||||
Spring Boot 3 + Kotlin, estrutura hexagonal conforme `01-ARQUITETURA.md` §3. Perfis `local`, `test`, `prod`. Actuator com `health`, `prometheus`, `info` expostos. OpenAPI via springdoc em `/v3/api-docs`.
|
||||
|
||||
Um controller `GET /api/v1/health` devolvendo `{"status":"UP"}`, só para provar a esteira ponta a ponta.
|
||||
|
||||
### 4. `web/` com pnpm workspaces
|
||||
|
||||
`pnpm-workspace.yaml` com `visitor`, `admin`, `shared-ui`. Vite + React 19 + TS em ambos os apps. `visitor` configurado como PWA.
|
||||
|
||||
`shared-ui` exporta `tokens.json` e o `tokens.css` gerado. Script `pnpm tokens:build` que gera `tokens.css` e `Theme.kt` a partir do JSON — ver `docs/04-DESIGN-SYSTEM-UX.md` §2.
|
||||
|
||||
### 5. `apps/` Compose Multiplatform
|
||||
|
||||
`composeApp` (comum), `androidApp`, `iosApp`. Depende de `:shared`. Uma tela "Hello Portaria" em ambas as plataformas, apenas para validar o build.
|
||||
|
||||
### 6. `infra/`
|
||||
|
||||
`docker-compose.yml` exatamente como em `docs/05-INFRA-DOCKER.md` §2, mais `docker-compose.observability.yml` e `.env.example`. Configs em `livekit/livekit.yaml`, `traefik/`, `postgres/init/01-extensions.sql` (`CREATE EXTENSION postgis; CREATE EXTENSION pgcrypto;`).
|
||||
|
||||
### 7. CI — GitHub Actions
|
||||
|
||||
```
|
||||
.github/workflows/ci.yml
|
||||
├─ gradle build (backend + shared, com Testcontainers)
|
||||
├─ pnpm lint && test && build
|
||||
├─ gate de bundle: visitor > 200KB gzip → FALHA
|
||||
├─ docker compose config
|
||||
└─ verificação de contraste dos tokens
|
||||
```
|
||||
|
||||
O gate de bundle não é opcional — a escolha de React em vez de Wasm foi motivada por TTI, e sem gate automático essa vantagem se perde em poucas sprints.
|
||||
|
||||
### 8. Documentação de arranque
|
||||
|
||||
`README.md` na raiz com pré-requisitos, `docker compose up`, como rodar cada app, e o mapa de portas.
|
||||
|
||||
## Critérios de aceite
|
||||
|
||||
- [ ] `./gradlew build` verde
|
||||
- [ ] `pnpm -r build` verde
|
||||
- [ ] `docker compose -f infra/docker-compose.yml config` sem erro
|
||||
- [ ] `docker compose up -d` sobe tudo; todos os healthchecks passam
|
||||
- [ ] `GET /api/v1/health` responde via Traefik com TLS
|
||||
- [ ] `/actuator/prometheus` expõe métricas
|
||||
- [ ] App Android e iOS compilam e abrem a tela de teste
|
||||
- [ ] `pnpm tokens:build` gera `tokens.css` e `Theme.kt`
|
||||
- [ ] CI verde no primeiro push
|
||||
|
||||
## Não faça nesta fase
|
||||
|
||||
- Nenhuma tabela de negócio (é a FASE 1)
|
||||
- Nenhum endpoint além do health
|
||||
- Nenhuma tela real
|
||||
- Nenhuma integração com LiveKit, FCM ou APNs
|
||||
99
prompts/FASE-1-dominio-e-dados.md
Normal file
99
prompts/FASE-1-dominio-e-dados.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# FASE 1 — Domínio compartilhado e camada de dados
|
||||
|
||||
## Objetivo
|
||||
|
||||
Modelo de domínio em `shared/`, schema completo no Postgres, infraestrutura de confiabilidade (outbox, fila, idempotência) funcionando, e autenticação. Ainda sem endpoints de negócio.
|
||||
|
||||
## Pré-requisitos
|
||||
|
||||
FASE 0 concluída. Ler `docs/02-MODELO-DE-DADOS.md` inteiro e `docs/01-ARQUITETURA.md` §4 e §5.
|
||||
|
||||
## Tarefas
|
||||
|
||||
### 1. Domínio em `shared/`
|
||||
|
||||
`state/VisitState.kt` e `VisitStateMachine.kt` conforme `01-ARQUITETURA.md` §4 e o diagrama de `03-FLUXOS-E-CONTRATOS.md` §1. A máquina de estados é a peça mais importante deste módulo — **teste toda transição válida e inválida**.
|
||||
|
||||
`model/` — enums e value objects: `VisitKind`, `DeliveryRule`, `MemberRole`, `MediaKind`, `DegradedMode`, `FeatureKey`, `GeoPoint`.
|
||||
|
||||
`dto/` — DTOs `@Serializable` de todos os contratos de `03-FLUXOS-E-CONTRATOS.md` §6.
|
||||
|
||||
`validation/` — validação de nome de visitante, telefone E.164, formato de unidade, tamanho de mídia. Usada no cliente e no servidor.
|
||||
|
||||
### 2. Migrations Flyway
|
||||
|
||||
As dez migrations de `02-MODELO-DE-DADOS.md` §11, exatamente na ordem indicada. Pontos que não podem ser esquecidos:
|
||||
|
||||
- `tenant_id uuid NOT NULL` em **toda** tabela de negócio
|
||||
- `version integer NOT NULL DEFAULT 0` onde há escrita concorrente
|
||||
- Nenhum `ON DELETE CASCADE` em dado auditável
|
||||
- Índice GIST em `gates.location`
|
||||
- Índice parcial em `visits` para estados ativos
|
||||
- `V8` revoga `UPDATE`/`DELETE` em `audit_log` para o usuário da aplicação
|
||||
- `V9` cria as policies de RLS e as deixa **desativadas**
|
||||
|
||||
### 3. jOOQ
|
||||
|
||||
Geração de código a partir do schema migrado (Testcontainers no build, não banco local). Repositórios para os agregados principais. **Todo repositório recebe `tenantId` como parâmetro obrigatório** — sem default, sem opcional. É o que torna a ativação futura do multi-tenant um interruptor.
|
||||
|
||||
### 4. Outbox transacional
|
||||
|
||||
```kotlin
|
||||
interface OutboxPublisher {
|
||||
fun enfileirar(evento: DomainEvent) // participa da transação corrente
|
||||
}
|
||||
```
|
||||
|
||||
Publisher que lê `outbox_events` pendentes com `FOR UPDATE SKIP LOCKED`, despacha, marca `published_at`, e em falha aplica backoff exponencial (`next_attempt_at`) e grava `last_error`. Após 10 tentativas, alerta.
|
||||
|
||||
**Teste obrigatório:** transação que falha após `enfileirar()` não pode deixar evento na tabela. É exatamente a garantia que o padrão existe para dar.
|
||||
|
||||
### 5. Fila de jobs e ShedLock
|
||||
|
||||
```kotlin
|
||||
interface JobScheduler {
|
||||
fun agendar(tipo: JobType, payload: JsonObject, quando: Instant): Long
|
||||
fun cancelar(jobId: Long)
|
||||
}
|
||||
```
|
||||
|
||||
Poller `@Scheduled(fixedDelay = 1000)` + `@SchedulerLock`, consumindo com `FOR UPDATE SKIP LOCKED`. Handlers registrados por `JobType`.
|
||||
|
||||
**Teste obrigatório com duas instâncias simultâneas:** cada job executa exatamente uma vez.
|
||||
|
||||
### 6. Idempotência
|
||||
|
||||
Filtro/interceptor que lê `Idempotency-Key`, consulta `idempotency_keys`, e:
|
||||
|
||||
- chave nova → executa e grava a resposta
|
||||
- chave repetida com mesmo `request_hash` → devolve a resposta original
|
||||
- chave repetida com corpo diferente → `422 IDEMPOTENCY_KEY_REUSE`
|
||||
|
||||
### 7. Segurança base
|
||||
|
||||
Spring Security com três cadeias de filtro conforme `06-LGPD-E-SEGURANCA.md` §6: `/api/v1/visitor/**` (JWT efêmero), `/api/v1/app/**` (OIDC), `/api/v1/admin/**` (OIDC + MFA).
|
||||
|
||||
Emissão e validação do JWT de visitante: 15 min, escopo de uma visita, atado a `gateId` e IP.
|
||||
|
||||
### 8. Auditoria
|
||||
|
||||
Aspecto que grava em `audit_log` toda mutação de entidade auditável, com `before`/`after` em `jsonb`.
|
||||
|
||||
## Critérios de aceite
|
||||
|
||||
- [ ] `./gradlew :shared:allTests` verde (JVM + iOS)
|
||||
- [ ] Cobertura de 100% das transições da máquina de estados, válidas e inválidas
|
||||
- [ ] `flyway migrate` do zero cria o schema completo
|
||||
- [ ] jOOQ gera código a partir do schema migrado
|
||||
- [ ] Teste: rollback de transação não deixa evento no outbox
|
||||
- [ ] Teste: duas instâncias do poller executam cada job exatamente uma vez
|
||||
- [ ] Teste: `UPDATE` em `audit_log` falha por permissão do banco
|
||||
- [ ] Teste: chave de idempotência repetida devolve a resposta original
|
||||
- [ ] Teste: repositório sem `tenantId` não compila
|
||||
|
||||
## Não faça nesta fase
|
||||
|
||||
- Endpoints de negócio (FASE 2)
|
||||
- Integração com LiveKit, FCM ou APNs
|
||||
- Qualquer tela
|
||||
- Ativar RLS
|
||||
107
prompts/FASE-2-backend-api.md
Normal file
107
prompts/FASE-2-backend-api.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# FASE 2 — API do backend
|
||||
|
||||
## Objetivo
|
||||
|
||||
Todos os endpoints REST, WebSocket de sinalização, armazenamento de mídia e o ciclo de vida da visita funcionando ponta a ponta — verificável por teste de integração, ainda sem interface.
|
||||
|
||||
## Pré-requisitos
|
||||
|
||||
FASES 0 e 1 concluídas. Ler `docs/03-FLUXOS-E-CONTRATOS.md` inteiro e `docs/06-LGPD-E-SEGURANCA.md` §5.
|
||||
|
||||
## Tarefas
|
||||
|
||||
### 1. Validação de QR e sessão de visitante
|
||||
|
||||
`GET /api/v1/visitor/gates/{gateId}/preview?s={sig}` — valida assinatura HMAC com `gates.qr_secret` e confere `qr_version`. Devolve nome do condomínio e o aviso de tratamento vigente. Sem autenticação.
|
||||
|
||||
`POST /api/v1/visitor/sessions` — recebe coordenadas, valida geofence com `ST_DWithin(gates.location, ponto, geofence_meters)`, registra `privacy_notices` (versão do aviso, IP, user agent) e emite o JWT efêmero.
|
||||
|
||||
Localização ausente ou negada → `403 LOCATION_REQUIRED`. Fora do raio → `403 OUTSIDE_GEOFENCE` com `detail` em português pronto para exibir.
|
||||
|
||||
### 2. Criação de visita — busca cega
|
||||
|
||||
`POST /api/v1/visitor/visits`, multipart (JSON + mídia).
|
||||
|
||||
**Esta é a implementação mais sensível do sistema.** Requisitos, todos obrigatórios:
|
||||
|
||||
- Resolve `unit_id` a partir de bloco + unidade; **se não existir, cria a visita com `unit_id = NULL`** e preserva `unit_input`
|
||||
- Resposta **idêntica** — mesmo corpo, mesmo status, mesmos headers — nos dois casos
|
||||
- **Tempo de resposta constante:** meça o caminho mais lento e aplique delay artificial no mais rápido. Sem isso o ataque vira timing attack e a busca cega não serve para nada
|
||||
- Nome de morador nunca aparece na resposta
|
||||
- Rate limit por IP e por dispositivo, com bloqueio progressivo
|
||||
|
||||
Verifica `quiet_hours` do condomínio: dentro da janela, a visita pula o toque ao morador e vai direto para `FILA_OPERADOR` ou `RECADO_EM_VIDEO`.
|
||||
|
||||
Enfileira `VisitaCriada` no outbox e agenda `VISIT_RING_TIMEOUT` (20s) e `VISIT_EXPIRE` (10 min).
|
||||
|
||||
### 3. Ciclo de vida da visita
|
||||
|
||||
Serviço de aplicação com as transições de `03-FLUXOS-E-CONTRATOS.md` §1, sempre validadas por `VisitStateMachine` e sempre com optimistic locking em `visits.version`.
|
||||
|
||||
Handlers de job:
|
||||
|
||||
| Job | Ação |
|
||||
|---|---|
|
||||
| `VISIT_RING_TIMEOUT` | → `ESCALONADA`; toca para os demais `unit_members` por `ring_order`; agenda `VISIT_ESCALATE` |
|
||||
| `VISIT_ESCALATE` | → `FILA_OPERADOR` se `operator_queue` ativo, senão `RECADO_EM_VIDEO` |
|
||||
| `VISIT_QUEUE_TIMEOUT` | → `RECADO_EM_VIDEO` |
|
||||
| `VISIT_EXPIRE` | → `EXPIRADA` |
|
||||
| `DELIVERY_DEFAULT_RULE` | aplica `units.delivery_rule` |
|
||||
|
||||
`POST /api/v1/app/visits/{id}/resolve` exige `Idempotency-Key`. Conflito de versão devolve `409 VISIT_ALREADY_RESOLVED` com `resolvedBy` e `resolvedAt` — o app precisa mostrar "Maria já autorizou", não um erro técnico.
|
||||
|
||||
### 4. Armazenamento de mídia
|
||||
|
||||
Porta `StoragePort` com adaptador MinIO/S3. Ao receber mídia:
|
||||
|
||||
- Valida `Content-Type` por **magic bytes**, nunca por extensão
|
||||
- Limites: 8MB foto, 30MB vídeo
|
||||
- **Remove EXIF** — carrega GPS próprio, que não é o dado que coletamos e não passou pelo aviso
|
||||
- Calcula `sha256` e grava em `media_assets`
|
||||
- Nome de objeto gerado pelo servidor
|
||||
- Define `expires_at` conforme a retenção do tipo (`06-LGPD-E-SEGURANCA.md` §4)
|
||||
|
||||
URL assinada de 5 min para leitura, **sempre** registrando acesso em `audit_log`. Nenhuma URL pública, em nenhuma hipótese.
|
||||
|
||||
### 5. WebSocket
|
||||
|
||||
`/ws/visitor/{visitId}` e `/ws/app`, com o envelope e os tipos de `03-FLUXOS-E-CONTRATOS.md` §7. Heartbeat de 20s.
|
||||
|
||||
**O WebSocket nunca é fonte de verdade.** Toda mudança de estado é confirmada por REST ou push. Se o WS cair, o cliente faz polling — e a portaria continua funcionando.
|
||||
|
||||
Com múltiplas réplicas do backend, a difusão de eventos usa Redis pub/sub (só transporte; a durabilidade fica no outbox).
|
||||
|
||||
### 6. Endpoints de app e admin
|
||||
|
||||
Todos os de `03-FLUXOS-E-CONTRATOS.md` §6, com autorização por método:
|
||||
|
||||
```kotlin
|
||||
@PreAuthorize("@access.canViewVisit(#visitId, authentication)")
|
||||
```
|
||||
|
||||
Morador vê apenas visitas das suas unidades; operador, apenas as da fila; admin, apenas as do seu tenant.
|
||||
|
||||
Importação CSV de unidades com **dry-run obrigatório** — devolve o que seria criado, alterado e rejeitado antes de gravar.
|
||||
|
||||
### 7. Tratamento de erros
|
||||
|
||||
`application/problem+json` (RFC 7807) com os `code` da tabela de `03-FLUXOS-E-CONTRATOS.md` §9. **`detail` sempre em português e pronto para exibir ao visitante** — a tela da portaria não é lugar para mensagem técnica.
|
||||
|
||||
## Critérios de aceite
|
||||
|
||||
- [ ] Testes de integração com Testcontainers cobrindo os fluxos A, B e C ponta a ponta
|
||||
- [ ] **Teste de busca cega:** unidade existente e inexistente produzem resposta idêntica, e a diferença de tempo fica abaixo do ruído de medição
|
||||
- [ ] Teste: geofence rejeita fora do raio e aceita dentro
|
||||
- [ ] Teste: `quiet_hours` desvia do morador
|
||||
- [ ] Teste: dois `resolve` concorrentes — um vence, outro recebe `409` com o autor
|
||||
- [ ] Teste: `resolve` repetido com mesma `Idempotency-Key` não duplica
|
||||
- [ ] Teste: escalonamento dispara após restart do backend (job persistido)
|
||||
- [ ] Teste: EXIF removido da imagem armazenada
|
||||
- [ ] Teste: acesso a mídia gera linha em `audit_log`
|
||||
- [ ] OpenAPI completo em `/v3/api-docs`
|
||||
|
||||
## Não faça nesta fase
|
||||
|
||||
- Integração com LiveKit (FASE 4) — `room_name` fica como placeholder
|
||||
- Push real (FASE 5) — `NotificationChannel` com implementação de log
|
||||
- Qualquer interface
|
||||
90
prompts/FASE-3-web-visitante.md
Normal file
90
prompts/FASE-3-web-visitante.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# FASE 3 — Web do visitante
|
||||
|
||||
## Objetivo
|
||||
|
||||
PWA do visitante completa: QR → aviso → permissões → captura → destino → espera narrada → resultado. Ambos os funis (visita e entrega). Sem videochamada ainda.
|
||||
|
||||
## Pré-requisitos
|
||||
|
||||
FASES 0–2 concluídas. Ler `docs/04-DESIGN-SYSTEM-UX.md` §3 e `docs/03-FLUXOS-E-CONTRATOS.md` §2 e §3.
|
||||
|
||||
## Contexto que determina cada decisão
|
||||
|
||||
O visitante está **em pé na calçada, no sol, com pressa, em 4G, e não vai instalar nada**. Tudo aqui deriva disso: bundle mínimo, uma ação por tela, tipografia grande, contraste alto.
|
||||
|
||||
## Tarefas
|
||||
|
||||
### 1. Base
|
||||
|
||||
Vite + React 19 + TS em `web/visitor`. Roteamento por `/v/{gateId}?s={sig}`. Tokens de `shared-ui`. PWA com manifest, **sem service worker de cache agressivo** — o visitante usa uma vez e o cache velho causaria mais problema que benefício.
|
||||
|
||||
**Orçamento: 200KB gzip**, com gate em CI. O SDK do LiveKit entra por `import()` dinâmico apenas na FASE 4, na tela de chamada — ele sozinho tem o tamanho de todo o resto do app.
|
||||
|
||||
### 2. Telas
|
||||
|
||||
Cinco telas conforme `04-DESIGN-SYSTEM-UX.md` §3, uma ação cada.
|
||||
|
||||
**Tela 1 — Aviso de tratamento.** Não é modal de consentimento com checkbox. A base legal é legítimo interesse; o que se faz é **informar com clareza**. Texto de `06-LGPD-E-SEGURANCA.md` §1, com link "Saiba mais". O botão diz "Continuar", não "Aceito".
|
||||
|
||||
**Tela 2 — Escolha do funil.** `📦 Entrega` primeiro, porque é a maioria do volume. `👤 Visita` abaixo.
|
||||
|
||||
**Tela 3 — Permissões e captura.** Solicita câmera e localização, com explicação **antes** do prompt nativo — pedir permissão sem contexto é a principal causa de negativa. Captura foto do rosto (visita) ou do pacote (entrega). Comprime no cliente para no máximo 1600px no lado maior.
|
||||
|
||||
Permissão negada: localização é bloqueante (`403 LOCATION_REQUIRED`), com instrução de como reabilitar. Câmera negada em entrega degrada para "sem foto"; em visita, é bloqueante.
|
||||
|
||||
**Tela 4 — Destino.** Bloco e unidade, campos livres. **Nenhum autocomplete, nenhuma sugestão, nenhuma validação de existência** — é a busca cega, e um autocomplete a destruiria. Nome do visitante obrigatório; documento e telefone opcionais.
|
||||
|
||||
**Tela 5 — Espera narrada.** A tela onde o produto se ganha ou se perde:
|
||||
|
||||
```
|
||||
TOCANDO "Chamando o morador..." barra 0→20s
|
||||
ESCALONADA "Tentando outros moradores..." barra 0→15s
|
||||
FILA_OPERADOR "Transferindo para a portaria" + posição na fila
|
||||
RECADO "Grave um recado em vídeo" → gravação
|
||||
```
|
||||
|
||||
Nunca um spinner mudo. Um spinner de 35 segundos é indistinguível de um app quebrado.
|
||||
|
||||
**Tela 6 — Resultado.** Autorizado: PIN de 6 dígitos em `display`, QR grande, contagem regressiva de validade. Negado: mensagem clara e sem tom acusatório. Expirado: instrução de contingência (botão físico da portaria).
|
||||
|
||||
### 3. Estado e conexão
|
||||
|
||||
Estado da visita via WebSocket com **fallback automático para polling** de 3s se o WS cair ou não conectar em 5s. O visitante em 4G instável é o caso comum, não a exceção.
|
||||
|
||||
Reconexão com backoff exponencial e teto de 10s. Estado da sessão em `sessionStorage`, para que refresh acidental não perca a visita.
|
||||
|
||||
### 4. Gravação de recado
|
||||
|
||||
`MediaRecorder`, 30s máximo, preview antes de enviar, upload com barra de progresso e retry.
|
||||
|
||||
### 5. Erros
|
||||
|
||||
Todo erro exibido vem do `detail` do `problem+json`, em português e acionável: **"Aproxime-se da entrada e tente novamente"**, nunca `OUTSIDE_GEOFENCE`.
|
||||
|
||||
### 6. Acessibilidade e legibilidade em campo
|
||||
|
||||
- Alvos de toque ≥ 48×48px, sem exceção
|
||||
- Corpo de texto ≥ 16px
|
||||
- Contraste ≥ 4.5:1, verificado em CI
|
||||
- Apenas extremos da escala neutra — sol direto derruba tons médios
|
||||
- `prefers-reduced-motion` respeitado
|
||||
- `lang="pt-BR"`, textos externalizados para i18n futura
|
||||
- Funciona em Safari iOS 15+ e Chrome Android 100+
|
||||
|
||||
## Critérios de aceite
|
||||
|
||||
- [ ] **Bundle ≤ 200KB gzip** (gate de CI)
|
||||
- [ ] TTI < 2s em 4G simulado (Lighthouse throttling)
|
||||
- [ ] Lighthouse: Performance ≥ 90, Acessibilidade = 100
|
||||
- [ ] Fluxo de entrega completo em ≤ 4 toques
|
||||
- [ ] Tela 4 não tem autocomplete nem validação de unidade
|
||||
- [ ] WS derrubado à força → polling assume e o fluxo continua
|
||||
- [ ] Localização negada → mensagem acionável, não erro técnico
|
||||
- [ ] Testado em iPhone e Android reais, sob sol
|
||||
- [ ] Todos os textos de erro em português, sem código técnico
|
||||
|
||||
## Não faça nesta fase
|
||||
|
||||
- Videochamada (FASE 4)
|
||||
- Service worker com cache de assets versionados
|
||||
- Qualquer tela de admin
|
||||
93
prompts/FASE-4-chamada.md
Normal file
93
prompts/FASE-4-chamada.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# FASE 4 — Videochamada
|
||||
|
||||
## Objetivo
|
||||
|
||||
LiveKit integrado ponta a ponta: emissão de tokens, sala por visita, vídeo no navegador do visitante, e degradação graciosa quando o SFU falha.
|
||||
|
||||
## Pré-requisitos
|
||||
|
||||
FASES 0–3 concluídas. Ler `docs/05-INFRA-DOCKER.md` §3 e §4, e `docs/01-ARQUITETURA.md` §5.5.
|
||||
|
||||
> **Faça a POC antes de escrever código de produção.** Suba `livekit-server` + `coturn` e feche uma chamada entre um navegador móvel em 4G real e um Android, medindo o tempo até o primeiro frame. É o maior risco técnico do projeto e o mais barato de derrubar cedo. Se a POC falhar, o problema quase sempre é a faixa UDP fechada no firewall.
|
||||
|
||||
## Tarefas
|
||||
|
||||
### 1. Porta de vídeo
|
||||
|
||||
```kotlin
|
||||
interface VideoCallProvider {
|
||||
suspend fun criarSala(visitId: UUID): RoomInfo
|
||||
suspend fun emitirToken(visitId: UUID, participante: Participant): String
|
||||
suspend fun encerrarSala(visitId: UUID)
|
||||
suspend fun saudavel(): Boolean
|
||||
}
|
||||
```
|
||||
|
||||
Adaptador `LiveKitProvider`. A porta existe para permitir trocar por serviço gerenciado sem tocar nos apps — decisão registrada em `01-ARQUITETURA.md` ADR-003.
|
||||
|
||||
### 2. Emissão de tokens
|
||||
|
||||
Token JWT do LiveKit gerado **pelo backend**, nunca pelo cliente. Escopo mínimo:
|
||||
|
||||
- `roomJoin` apenas na sala daquela visita
|
||||
- `canPublish` para vídeo e áudio
|
||||
- `canPublishData: false`
|
||||
- TTL de 10 minutos, alinhado à expiração da visita
|
||||
|
||||
`room_name` derivado determinísticamente de `visitId`, gravado em `visits.room_name`.
|
||||
|
||||
**Um token nunca dá acesso a outra sala.** Verifique isso com teste explícito.
|
||||
|
||||
### 3. Ciclo de vida da sala
|
||||
|
||||
Sala criada na transição para `EM_CHAMADA`, não antes — criar em `PENDENTE` desperdiça recurso do SFU em visitas que nunca são atendidas.
|
||||
|
||||
Encerrada ao chegar em estado final. `empty_timeout: 120` no LiveKit é a rede de segurança para sala órfã.
|
||||
|
||||
### 4. Vídeo na web do visitante
|
||||
|
||||
SDK `livekit-client` carregado por **`import()` dinâmico**, apenas quando a visita entra em `EM_CHAMADA`. Ele sozinho pesa mais que todo o resto do app — carregá-lo no bundle inicial destruiria o orçamento de 200KB da FASE 3.
|
||||
|
||||
Vídeo remoto em tela cheia, auto-preview pequeno no canto, botão de mudo e de encerrar. Indicador de qualidade de conexão.
|
||||
|
||||
### 5. Degradação graciosa
|
||||
|
||||
Circuit breaker (Resilience4j) sobre o `VideoCallProvider`. Ao abrir:
|
||||
|
||||
```
|
||||
LiveKit fora ─► AUDIO (mesma sala, sem vídeo, muito menos banda)
|
||||
└─► FOTO_TEXTO (visitante manda foto, morador aprova sem chamada)
|
||||
└─► FILA_OPERADOR (resolução por telefone)
|
||||
```
|
||||
|
||||
O nível vigente vai em `visits.degraded_mode`, é emitido como `DEGRADED_MODE` no WebSocket, aparece em `/actuator/health` e no painel admin.
|
||||
|
||||
**Comunique a degradação, nunca a esconda.** Esconder faz o usuário achar que o produto quebrou; nomear faz o produto parecer resiliente — que é o que ele é.
|
||||
|
||||
### 6. Configuração de mídia
|
||||
|
||||
**Limite de resolução imposto no servidor**, não no cliente: 480p a ~600kbps. 480p basta para reconhecer alguém na portaria; 1080p multiplicaria o custo de banda por quatro sem melhorar nenhuma decisão. Ver o dimensionamento em `05-INFRA-DOCKER.md` §4.
|
||||
|
||||
Simulcast desligado (só dois participantes). `adaptiveStream` e `dynacast` ligados. TURN configurado com o segredo do coturn.
|
||||
|
||||
### 7. Métricas
|
||||
|
||||
`portaria.call.time_to_first_frame` · `portaria.call.duration` · `portaria.call.failed_ratio` · `portaria.call.turn_usage_ratio` (uso alto de TURN indica problema de rede na portaria) · `portaria.call.degraded_ratio`.
|
||||
|
||||
## Critérios de aceite
|
||||
|
||||
- [ ] POC validada em rede real antes do código de produção
|
||||
- [ ] Chamada completa entre navegador móvel em 4G e Android
|
||||
- [ ] Tempo até o primeiro frame < 3s em rede boa
|
||||
- [ ] Teste: token de uma sala é rejeitado em outra sala
|
||||
- [ ] Teste: com LiveKit derrubado, o sistema degrada para áudio e depois para foto+texto
|
||||
- [ ] Teste: sala encerrada em todos os estados finais
|
||||
- [ ] SDK do LiveKit **não** aparece no bundle inicial do visitante
|
||||
- [ ] Bundle inicial continua ≤ 200KB gzip
|
||||
- [ ] Chamada funciona atrás de NAT simétrico (valida o coturn)
|
||||
|
||||
## Não faça nesta fase
|
||||
|
||||
- Gravação (módulo `video_recording`, FASE 8)
|
||||
- App do morador (FASE 5)
|
||||
- Mais de 2 participantes por sala
|
||||
91
prompts/FASE-5-app-morador.md
Normal file
91
prompts/FASE-5-app-morador.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# FASE 5 — App do morador
|
||||
|
||||
## Objetivo
|
||||
|
||||
App Compose Multiplatform (Android + iOS) que **toca de verdade** com o app fechado, permite resolver entrega em um toque na notificação, e atende videochamada.
|
||||
|
||||
## Pré-requisitos
|
||||
|
||||
FASES 0–4 concluídas. Ler `docs/04-DESIGN-SYSTEM-UX.md` §4.
|
||||
|
||||
## O ponto que define esta fase
|
||||
|
||||
**No iOS, notificação comum não faz o telefone tocar como chamada.** Só PushKit + CallKit fazem. Sem isso o app não funciona como portaria — o morador vê a chamada quando abrir o celular, dez minutos depois, e o visitante já foi embora.
|
||||
|
||||
E a Apple **obriga**: ao receber um push do PushKit, o app precisa reportar a chamada ao CallKit imediatamente, na mesma execução. Não reportar faz o sistema matar o app e, com reincidência, revogar o direito de receber VoIP push. Não há meio-termo aqui.
|
||||
|
||||
## Tarefas
|
||||
|
||||
### 1. Estrutura
|
||||
|
||||
`apps/composeApp` com Compose Multiplatform, dependendo de `:shared`. Arquitetura MVVM com `ViewModel` compartilhado. Ktor Client para HTTP e WebSocket. Material 3 Expressive com o `Theme.kt` gerado dos tokens.
|
||||
|
||||
Um único app com dois perfis (morador e operador), decididos por `GET /me`.
|
||||
|
||||
### 2. Notificação de chamada — Android
|
||||
|
||||
FCM com prioridade `high`. Notificação `CallStyle` com full-screen intent para visita; notificação com ações para entrega.
|
||||
|
||||
Permissões necessárias: `POST_NOTIFICATIONS` (13+), `USE_FULL_SCREEN_INTENT` (14+), `FOREGROUND_SERVICE_MICROPHONE` e `FOREGROUND_SERVICE_CAMERA`.
|
||||
|
||||
**Otimização de bateria é o inimigo silencioso.** Xiaomi, Samsung e Huawei matam apps agressivamente e o push nunca chega. O app precisa detectar e orientar o usuário a isentá-lo — e o painel admin precisa mostrar quem está com push falhando (`05-INFRA-DOCKER.md` §6).
|
||||
|
||||
### 3. Notificação de chamada — iOS
|
||||
|
||||
**Visita** → PushKit VoIP push → `CXProvider.reportNewIncomingCall()` **imediatamente**, antes de qualquer chamada de rede. Só depois busque os detalhes da visita.
|
||||
|
||||
**Entrega** → notificação comum com `UNNotificationCategory` e ações. CallKit para uma entrega seria intrusivo e é risco de reprovação na App Review.
|
||||
|
||||
Entitlement de VoIP configurado. Chave APNs `.p8` no backend. Áudio configurado via `AVAudioSession` com categoria `playAndRecord`.
|
||||
|
||||
**Nota de App Review:** documente na submissão que o app é VoIP legítimo de controle de acesso. Descreva o fluxo. Apps que usam CallKit para notificação genérica são reprovados — este não é o caso, mas o revisor precisa entender por quê.
|
||||
|
||||
### 4. Ações de um toque
|
||||
|
||||
O morador resolve **sem abrir o app**:
|
||||
|
||||
- Android: `Notification.Action` com `PendingIntent` para um `BroadcastReceiver`
|
||||
- iOS: `UNNotificationAction` tratada na extensão
|
||||
|
||||
Ambas chamam `POST /visits/{id}/resolve` com `Idempotency-Key` gerada no dispositivo. **Trate a falha de rede com retry**: a ação disparada em rede ruim que falha em silêncio é a pior falha possível deste app — o morador acha que autorizou e o entregador continua parado.
|
||||
|
||||
### 5. Telas
|
||||
|
||||
**Home** — pendências (recados não resolvidos) no topo, histórico abaixo, estado de conexão visível.
|
||||
|
||||
**Chamada** — vídeo do visitante em tela cheia, auto-preview no canto. Sobreposto: nome informado, unidade de destino, e o **selo de geofence** (`✓ Na entrada` verde / `⚠ A 340m da portaria` âmbar). Esse selo é a informação de segurança mais útil da tela — alguém acionando de longe é sinal claro. Ações: `Autorizar` (verde, primária), `Negar`, mudo, encerrar.
|
||||
|
||||
**Detalhe da visita** — foto, mapa do ponto, timeline de `visit_attempts`, player do recado.
|
||||
|
||||
**Configurações** — regra de entrega por unidade, ordem de toque, dispositivos ativos, notificações.
|
||||
|
||||
### 6. Registro de dispositivo
|
||||
|
||||
`POST /devices` no login e a cada renovação de token, enviando `push_token` e — no iOS — também `voip_token`. Renove sempre que o sistema emitir novo token; token expirado é a causa número um de "não recebi a chamada".
|
||||
|
||||
### 7. Concorrência entre moradores
|
||||
|
||||
Ao receber `409 VISIT_ALREADY_RESOLVED`, mostre **"Maria já autorizou"** com horário. Nunca um erro técnico — dois moradores respondendo juntos é situação normal, não falha.
|
||||
|
||||
### 8. Tempo real
|
||||
|
||||
WebSocket enquanto o app está em foreground, com fallback para push. Ao voltar do background, reconcilia estado via REST antes de confiar no WS.
|
||||
|
||||
## Critérios de aceite
|
||||
|
||||
- [ ] **iOS: chamada toca em tela cheia com o app fechado e o telefone bloqueado**
|
||||
- [ ] **Android: full-screen intent aparece com o app fechado e a tela apagada**
|
||||
- [ ] Entrega resolvida pela notificação, sem abrir o app, em ambas as plataformas
|
||||
- [ ] Teste com o app morto por otimização de bateria (Xiaomi/Samsung)
|
||||
- [ ] Videochamada funciona com o app vindo do background
|
||||
- [ ] `409` mostra quem já resolveu
|
||||
- [ ] Ação em rede ruim tem retry e não falha em silêncio
|
||||
- [ ] Registro de dispositivo renova o token corretamente
|
||||
- [ ] Testado em dispositivos físicos — simulador não recebe push
|
||||
- [ ] Acessibilidade: TalkBack e VoiceOver navegam a tela de chamada
|
||||
|
||||
## Não faça nesta fase
|
||||
|
||||
- App do operador completo (FASE 7) — perfil de operador fica oculto
|
||||
- WhatsApp (FASE 8)
|
||||
- Autorizações recorrentes (v2)
|
||||
106
prompts/FASE-6-painel-admin.md
Normal file
106
prompts/FASE-6-painel-admin.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# FASE 6 — Painel administrativo
|
||||
|
||||
## Objetivo
|
||||
|
||||
Painel web completo para síndico e administradora: gestão do condomínio, auditoria de visitas com mídia e mapa, importação de moradores, reconciliação e feature flags.
|
||||
|
||||
## Pré-requisitos
|
||||
|
||||
FASES 0–5 concluídas. Ler `docs/04-DESIGN-SYSTEM-UX.md` §6 e `docs/03-FLUXOS-E-CONTRATOS.md` §6.
|
||||
|
||||
## Contexto
|
||||
|
||||
Ao contrário da tela do visitante, aqui **densidade é virtude**: usuário recorrente, em desktop, que precisa de tabela grande, filtro fino e comparação. O orçamento de bundle não se aplica.
|
||||
|
||||
## Tarefas
|
||||
|
||||
### 1. Base
|
||||
|
||||
Vite + React 19 + TS em `web/admin`. TanStack Router e TanStack Query. TanStack Table para grades virtualizadas. Leaflet para mapa (tiles OSM). Tokens de `shared-ui`.
|
||||
|
||||
Login OIDC com **MFA obrigatório** — este perfil vê fotos de todos os visitantes e a localização de todos os acessos; é o alvo mais valioso do sistema.
|
||||
|
||||
### 2. Auditoria de visitas — a tela mais importante
|
||||
|
||||
Tabela virtualizada com filtros persistidos na URL: período, condomínio, bloco, unidade, tipo, estado, **dentro/fora do geofence**, e quem resolveu.
|
||||
|
||||
Colunas: data/hora, tipo, visitante, unidade, estado, quem resolveu, duração, selo de geofence.
|
||||
|
||||
**Detalhe da visita:**
|
||||
|
||||
- Foto do visitante ou do pacote (URL assinada de 5 min)
|
||||
- **Mapa** com o ponto do visitante e o raio do portão desenhado — é aqui que uma tentativa remota fica óbvia
|
||||
- Timeline de `visit_attempts`: quem foi notificado, por qual canal, entregue ou não, quem atendeu. **É o que responde "por que ninguém atendeu?"** — a pergunta mais comum do síndico
|
||||
- Player do recado, se houver
|
||||
- Registro de exibição do aviso (`privacy_notices`) com a versão do texto
|
||||
- Hash `sha256` da mídia, para valor probatório
|
||||
|
||||
Exportação CSV e PDF do resultado filtrado, com marca d'água de quem exportou e quando.
|
||||
|
||||
**Todo acesso a mídia gera linha em `audit_log`** — inclusive o do admin. Isso não é opcional.
|
||||
|
||||
### 3. Gestão do condomínio
|
||||
|
||||
CRUD de condomínios, blocos, unidades, pessoas, vínculos, portarias e responsáveis pela abertura.
|
||||
|
||||
**Importação CSV de unidades e moradores com dry-run obrigatório**: mostra o que será criado, alterado e rejeitado, e só grava após confirmação. Importar uma base errada em cima de outra correta é dano difícil de reverter.
|
||||
|
||||
Convite de morador por link, com acompanhamento (enviado / aberto / aceito).
|
||||
|
||||
Configuração da portaria: posição no mapa, `geofence_meters` com o raio desenhado para calibragem visual, `qr_version` com botão de invalidar-e-reimprimir, e geração do PDF do QR pronto para impressão (fosco, 15×15cm, conforme `07-REQUISITOS-DE-CAMPO.md`).
|
||||
|
||||
### 4. Reconciliação
|
||||
|
||||
Tela dedicada ao relatório de `GET /admin/reconciliation`:
|
||||
|
||||
- Moradores com `valid_until` vencido
|
||||
- **Unidades sem nenhum dispositivo ativo** — estas nunca atendem
|
||||
- Dispositivos com push falhando há mais de 7 dias (token morto = desinstalou)
|
||||
- Unidades com taxa de atendimento abaixo de 30% no mês
|
||||
- Convites nunca aceitos
|
||||
|
||||
Cada item com ação direta: reenviar convite, marcar como saído, contatar.
|
||||
|
||||
Esta tela é o que mantém o produto vivo depois do primeiro mês — base desatualizada quebra tudo silenciosamente.
|
||||
|
||||
### 5. Configuração do app do morador
|
||||
|
||||
O que o síndico controla no app dos moradores: regra padrão de entrega por unidade, `ring_order`, `quiet_hours` do condomínio, e quais funcionalidades aparecem.
|
||||
|
||||
### 6. Planos e módulos
|
||||
|
||||
Visualização do plano do tenant e toggles de `tenant_features`: `whatsapp_notifications`, `operator_queue`, `video_recording`, `access_control_hardware`, `recurring_authorizations`.
|
||||
|
||||
Cada toggle mostra o efeito prático em uma frase — "Sem operador, visitas não atendidas viram recado em vídeo" — e registra a mudança em `audit_log` com autor.
|
||||
|
||||
### 7. SLOs e saúde
|
||||
|
||||
Dashboard de `GET /admin/slo` com as métricas de `00-VISAO-E-PRODUTO.md` §6: latência QR→toque, taxa de atendimento, latência de entrega, abandono.
|
||||
|
||||
**Modo degradado vigente exibido com destaque.** O síndico precisa saber que o sistema está reduzido antes de receber a reclamação.
|
||||
|
||||
### 8. LGPD
|
||||
|
||||
Atendimento a titulares: busca por CPF ou telefone, exportação JSON, e eliminação — que apaga mídia e anonimiza nome **preservando a linha da visita** (`06-LGPD-E-SEGURANCA.md` §3).
|
||||
|
||||
Cadastro do encarregado (DPO), exibido no aviso de privacidade. Editor do texto do aviso com **versionamento** — mudar o texto cria nova versão, e as visitas antigas continuam referenciando a versão que foi realmente exibida.
|
||||
|
||||
## Critérios de aceite
|
||||
|
||||
- [ ] Login exige MFA
|
||||
- [ ] Tabela de auditoria fluida com 100 mil visitas (virtualização)
|
||||
- [ ] Mapa mostra ponto do visitante e raio do portão
|
||||
- [ ] Timeline de tentativas explica por que ninguém atendeu
|
||||
- [ ] Acesso a mídia usa URL assinada e gera auditoria
|
||||
- [ ] Importação CSV com dry-run funcional
|
||||
- [ ] Relatório de reconciliação identifica unidades sem dispositivo ativo
|
||||
- [ ] PDF do QR sai no tamanho e formato de `07-REQUISITOS-DE-CAMPO.md`
|
||||
- [ ] Toggle de módulo registra autor em `audit_log`
|
||||
- [ ] Eliminação LGPD preserva a linha anonimizada
|
||||
- [ ] Acessibilidade: navegação completa por teclado, contraste ≥ 4.5:1
|
||||
|
||||
## Não faça nesta fase
|
||||
|
||||
- Billing automatizado (v2)
|
||||
- Gestão de tenants (multi-tenancy segue desativada)
|
||||
- App do operador (FASE 7)
|
||||
91
prompts/FASE-7-escalonamento.md
Normal file
91
prompts/FASE-7-escalonamento.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# FASE 7 — Escalonamento, fila de operador e recado
|
||||
|
||||
## Objetivo
|
||||
|
||||
Completar a retaguarda: escalonamento entre moradores, fila de operador humano com contexto, app do operador, e recado em vídeo. É o que transforma "chamada não atendida" em "visita resolvida".
|
||||
|
||||
## Pré-requisitos
|
||||
|
||||
FASES 0–6 concluídas. Ler `docs/03-FLUXOS-E-CONTRATOS.md` §4 e `docs/00-VISAO-E-PRODUTO.md` §1.
|
||||
|
||||
## Por que esta fase existe
|
||||
|
||||
A tese do produto é que o morador atende. A realidade é que ele nem sempre atende — gente em reunião, dirigindo, dormindo. É por isso que o mercado consolidado usa operador humano 24h.
|
||||
|
||||
Esta fase é o que torna a aposta vendável: quando o morador não atende, o visitante **não fica sem saída**. E é o que sustenta comercialmente o plano Assistido.
|
||||
|
||||
## Tarefas
|
||||
|
||||
### 1. Escalonamento entre moradores
|
||||
|
||||
`VISIT_RING_TIMEOUT` (20s) → `ESCALONADA`. Toca para os demais `unit_members` da unidade, **em paralelo**, respeitando `ring_order` para ordenar a exibição e `receives_calls`.
|
||||
|
||||
O primeiro que atender vence; os demais recebem cancelamento da notificação — deixar notificação órfã tocando depois de resolvido é ruído que faz o morador desativar o app.
|
||||
|
||||
### 2. Fila de operador
|
||||
|
||||
Ativa apenas com `tenant_features.operator_queue` habilitado. Sem o módulo, `ESCALONADA` vai direto para `RECADO_EM_VIDEO`.
|
||||
|
||||
Fila ordenada por tempo de espera, com prioridade para `VISITA` sobre `ENTREGA`. `POST /operator/queue/claim` usa `FOR UPDATE SKIP LOCKED` — dois operadores nunca pegam a mesma visita.
|
||||
|
||||
Posição na fila enviada ao visitante por `QUEUE_POSITION` no WebSocket. Esperar é tolerável; esperar sem saber quanto falta, não.
|
||||
|
||||
`VISIT_QUEUE_TIMEOUT` (120s) → `RECADO_EM_VIDEO`.
|
||||
|
||||
### 3. App do operador
|
||||
|
||||
Mesmo binário Compose Multiplatform, perfil decidido por `GET /me`. Layout mais denso que o do morador.
|
||||
|
||||
**Fila** à esquerda: foto, tipo, unidade, tempo de espera e **por que escalonou** (ninguém atendeu / quiet hours / unidade inexistente).
|
||||
|
||||
**Contexto** à direita, antes de assumir: histórico da unidade, visitas recentes, regra de entrega, nomes dos moradores, telefone. **O operador nunca atende sem contexto** — atender às cegas é o que faz a portaria remota parecer pior que a física.
|
||||
|
||||
Presença: `DISPONIVEL` / `EM_ATENDIMENTO` / `OFFLINE`, com heartbeat. Operador que perde conexão volta para `OFFLINE` automaticamente e suas visitas retornam à fila.
|
||||
|
||||
Ao resolver, o operador registra `resolution_reason` — obrigatório, e é o que alimenta a análise de por que o modelo autônomo falhou naquele caso.
|
||||
|
||||
### 4. Recado em vídeo
|
||||
|
||||
`RECADO_EM_VIDEO`: o visitante grava até 30s. Salvo em `media_assets` com `kind = RECADO_VIDEO` e retenção de 30 dias.
|
||||
|
||||
Notificação ao morador **sem urgência** — não é CallKit, não é full-screen intent. É pendência, não chamada.
|
||||
|
||||
Aparece no topo da home do app até ser resolvida. O morador pode autorizar retroativamente (gerando `access_grant` com validade estendida), negar, ou apenas marcar como visto.
|
||||
|
||||
### 5. Quiet hours
|
||||
|
||||
Dentro de `condominiums.quiet_hours`, visitas não pré-autorizadas **pulam o toque ao morador**: vão direto para `FILA_OPERADOR` (se o módulo estiver ativo) ou `RECADO_EM_VIDEO`.
|
||||
|
||||
É a defesa contra tocar em todos os apartamentos de madrugada, e precisa ser óbvia para o visitante: "Fora do horário. Transferindo para a portaria."
|
||||
|
||||
### 6. Cancelamento de notificações
|
||||
|
||||
Ao resolver a visita, cancele as notificações pendentes em todos os dispositivos que foram acionados. Android usa `notificationId` estável por visita; iOS usa `CXProvider.reportCall(with:endedAt:reason:)`.
|
||||
|
||||
Uma chamada CallKit que continua tocando depois de resolvida é bug grave de percepção — o morador acha que o app está quebrado.
|
||||
|
||||
### 7. Métricas de escalonamento
|
||||
|
||||
`portaria.escalation.ratio` (quantas visitas escalaram) · `portaria.queue.wait_time` · `portaria.queue.abandoned` · `portaria.message.recorded` · `portaria.message.unresolved_24h`.
|
||||
|
||||
`escalation.ratio` é a métrica que decide se um condomínio fica no plano Autônomo ou precisa migrar para o Assistido.
|
||||
|
||||
## Critérios de aceite
|
||||
|
||||
- [ ] Escalonamento toca para os demais moradores após 20s
|
||||
- [ ] Primeiro a atender vence; notificações dos outros são canceladas
|
||||
- [ ] Sem `operator_queue`, escalonada vai direto para recado
|
||||
- [ ] Dois operadores não pegam a mesma visita (teste concorrente)
|
||||
- [ ] Posição na fila chega ao visitante em tempo real
|
||||
- [ ] Operador que perde conexão devolve a visita à fila
|
||||
- [ ] Recado gravado, armazenado e notificado sem urgência
|
||||
- [ ] Recado aparece como pendência na home até resolver
|
||||
- [ ] Quiet hours desvia sem tocar em ninguém, com aviso ao visitante
|
||||
- [ ] CallKit não continua tocando após resolução
|
||||
- [ ] Fluxo completo sem morador algum: QR → escalonamento → fila → operador → autorizado
|
||||
|
||||
## Não faça nesta fase
|
||||
|
||||
- Pré-autorização e recorrentes (v2)
|
||||
- WhatsApp (FASE 8)
|
||||
- Escalação para telefone/PSTN
|
||||
126
prompts/FASE-8-planos-e-lgpd.md
Normal file
126
prompts/FASE-8-planos-e-lgpd.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# FASE 8 — Planos, módulos, LGPD e observabilidade
|
||||
|
||||
## Objetivo
|
||||
|
||||
Fechar o que torna o produto operável e vendável: módulos ligáveis por tenant, canal de WhatsApp preparado (interface, não integração), retenção e expurgo automáticos, direitos do titular, e os SLOs medidos de verdade.
|
||||
|
||||
## Pré-requisitos
|
||||
|
||||
FASES 0–7 concluídas. Ler `docs/06-LGPD-E-SEGURANCA.md` §3 e §4, e `docs/05-INFRA-DOCKER.md` §6.
|
||||
|
||||
## Tarefas
|
||||
|
||||
### 1. Canais de notificação plugáveis
|
||||
|
||||
```kotlin
|
||||
interface NotificationChannel {
|
||||
val key: FeatureKey? // null = sempre ativo
|
||||
suspend fun notify(alvo: NotificationTarget, evento: NotificationEvent): Result<Unit>
|
||||
}
|
||||
```
|
||||
|
||||
`PushChannel` (FCM + APNs) sempre ativo. `WebSocketChannel` sempre ativo.
|
||||
|
||||
`NotificationDispatcher` resolve os canais habilitados para o tenant e despacha **em paralelo**, tolerando falha individual — um canal fora do ar não pode impedir os demais.
|
||||
|
||||
### 2. WhatsApp — interface e flag, sem integração
|
||||
|
||||
O WhatsApp é **módulo pago opcional**; o app é o notificador principal. Nesta fase entrega-se apenas:
|
||||
|
||||
- `WhatsAppChannel` com `key = FeatureKey.WHATSAPP_NOTIFICATIONS`, implementação **stub** que registra o que seria enviado
|
||||
- Toggle no painel admin, com o efeito descrito em uma frase
|
||||
- Estrutura de templates prevista
|
||||
- Documentação em `docs/anexos/whatsapp-v2.md` com o que falta: WABA verificada, templates `utility` aprovados, custo (~US$0,004/msg no Brasil, grátis dentro da janela de 24h)
|
||||
|
||||
**Não integre com a Meta agora.** Verificação de negócio e aprovação de template levam dias e bloqueariam o lançamento. E registre no documento a limitação de fundo: a latência do WhatsApp não serve para *tocar* uma chamada — ele é aviso paralelo com deep link, nunca o canal primário.
|
||||
|
||||
### 3. Feature flags
|
||||
|
||||
```kotlin
|
||||
@Service
|
||||
class FeatureService {
|
||||
fun habilitado(tenantId: UUID, key: FeatureKey): Boolean
|
||||
fun dentroDaQuota(tenantId: UUID, key: FeatureKey, uso: Int): Boolean
|
||||
}
|
||||
```
|
||||
|
||||
Cache curto (60s) com invalidação ao alterar. Toda mudança de flag vai para `audit_log` com autor.
|
||||
|
||||
Quotas relevantes: minutos de vídeo por mês, gravações armazenadas, operadores simultâneos.
|
||||
|
||||
### 4. Gravação de chamada
|
||||
|
||||
Módulo `video_recording`. LiveKit Egress grava para o MinIO ao entrar em `EM_CHAMADA`, se habilitado.
|
||||
|
||||
**Aviso reforçado obrigatório:** ao gravar, o visitante vê aviso explícito antes do início, e a versão desse aviso é registrada. Gravar sem avisar é violação direta.
|
||||
|
||||
Retenção de 90 dias. Cada gravação soma banda no SFU — ative com quota.
|
||||
|
||||
### 5. Retenção e expurgo
|
||||
|
||||
Job diário `MEDIA_PURGE`:
|
||||
|
||||
1. Seleciona `media_assets` com `expires_at < now()` e `storage_key IS NOT NULL`
|
||||
2. Apaga o objeto no MinIO
|
||||
3. `UPDATE media_assets SET storage_key = NULL, purged_at = now()`
|
||||
4. **Preserva a linha**
|
||||
|
||||
O passo 4 é o ponto: fica provado que existiu uma foto e que ela foi eliminada no prazo — exatamente o que se demonstra numa fiscalização.
|
||||
|
||||
Prazos configuráveis por tenant, com **teto** definido pela plataforma. Um condomínio querendo guardar foto por 5 anos configuraria um risco que a plataforma não aceita hospedar.
|
||||
|
||||
Job diário de limpeza de `idempotency_keys` vencidas.
|
||||
|
||||
### 6. Direitos do titular
|
||||
|
||||
`POST /admin/lgpd/export` — busca por CPF ou telefone, devolve JSON com todas as visitas, mídias (URLs assinadas) e registros de aviso.
|
||||
|
||||
`POST /admin/lgpd/erase` — apaga mídia, anonimiza `visitor_name` para `[REMOVIDO]`, limpa documento e telefone, **preserva a linha da visita** com data, unidade e resultado. Justificativa: obrigação de guarda de registro de acesso para segurança patrimonial. A linha vira estatística, não identificação.
|
||||
|
||||
Ambas registram em `audit_log` e devolvem comprovante em PDF — o condomínio precisa provar que atendeu no prazo de 15 dias.
|
||||
|
||||
### 7. Versionamento do aviso de privacidade
|
||||
|
||||
Editor no admin. Alterar o texto **cria nova versão**; as visitas antigas continuam apontando para a versão que foi realmente exibida. Sem isso, não há como provar o que o visitante leu naquele dia.
|
||||
|
||||
### 8. SLOs medidos
|
||||
|
||||
Instrumentar as métricas de `01-ARQUITETURA.md` §5.7:
|
||||
|
||||
```
|
||||
portaria.visit.ring_latency QR → push entregue no dispositivo
|
||||
portaria.visit.answered_ratio atendidas pelo morador / total
|
||||
portaria.delivery.resolution_latency QR → resolvida
|
||||
portaria.delivery.abandoned_ratio canceladas ou expiradas
|
||||
portaria.entry_flow.availability caminho crítico
|
||||
```
|
||||
|
||||
`ring_latency` mede até a **entrega** do push, não até o envio. A diferença entre os dois é justamente onde a falha acontece.
|
||||
|
||||
Dashboards e alertas conforme `05-INFRA-DOCKER.md` §6, incluindo os dois alertas de negócio (taxa de atendimento baixa e tokens mortos) que viram tarefa de customer success, não plantão.
|
||||
|
||||
### 9. Runbooks
|
||||
|
||||
`docs/anexos/runbook-incidente.md` — vazamento, indisponibilidade, perda de dados; com prazos ANPD.
|
||||
`docs/anexos/runbook-operacao.md` — restore testado, rotação de segredo, invalidação de QR, adição de condomínio.
|
||||
`docs/anexos/LIA-modelo.md` — modelo de Legitimate Interest Assessment para preenchimento por condomínio.
|
||||
|
||||
## Critérios de aceite
|
||||
|
||||
- [ ] Dispatcher despacha em paralelo e tolera falha de um canal
|
||||
- [ ] `WhatsAppChannel` desligado não é invocado; ligado, registra o que enviaria
|
||||
- [ ] Toggle de módulo tem efeito imediato e fica em `audit_log`
|
||||
- [ ] Gravação só ocorre com módulo ativo e aviso reforçado exibido
|
||||
- [ ] `MEDIA_PURGE` apaga o objeto e preserva a linha
|
||||
- [ ] Exportação LGPD devolve todos os dados de um titular
|
||||
- [ ] Eliminação anonimiza e preserva a linha da visita
|
||||
- [ ] Alterar o aviso cria versão; visitas antigas mantêm a original
|
||||
- [ ] Todos os SLOs visíveis no Grafana com dados reais
|
||||
- [ ] Alertas disparam em condição simulada
|
||||
- [ ] Restore de backup testado e cronometrado
|
||||
|
||||
## Não faça nesta fase
|
||||
|
||||
- Integração real com a Meta (v2)
|
||||
- Billing automatizado (v2)
|
||||
- Ativar multi-tenancy
|
||||
126
prompts/FASE-9-hardening.md
Normal file
126
prompts/FASE-9-hardening.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# FASE 9 — Hardening e produção
|
||||
|
||||
## Objetivo
|
||||
|
||||
Provar que o sistema aguenta o mundo real: testes ponta a ponta, carga no SFU, injeção de falhas, revisão de segurança e deploy documentado.
|
||||
|
||||
## Pré-requisitos
|
||||
|
||||
FASES 0–8 concluídas. Ler `docs/06-LGPD-E-SEGURANCA.md` §5 e `docs/07-REQUISITOS-DE-CAMPO.md`.
|
||||
|
||||
## Tarefas
|
||||
|
||||
### 1. Testes ponta a ponta
|
||||
|
||||
Playwright para os webs, Maestro para os apps. Cenários obrigatórios:
|
||||
|
||||
1. Visita completa: QR → aviso → permissões → foto → unidade → morador atende → autoriza → PIN
|
||||
2. Entrega em um toque, resolvida pela notificação, em menos de 10s
|
||||
3. Escalonamento completo: ninguém atende → outros moradores → fila → operador → autorizado
|
||||
4. Recado: ninguém atende, sem módulo de operador → grava → morador resolve depois
|
||||
5. Unidade inexistente: visita criada, ninguém notificado, expira — **resposta indistinguível de unidade válida**
|
||||
6. Fora do geofence: bloqueado com mensagem acionável
|
||||
7. Quiet hours: desvia sem tocar em ninguém
|
||||
8. Dois moradores respondendo juntos: um vence, o outro vê quem resolveu
|
||||
9. LiveKit fora: degrada para áudio, depois foto+texto
|
||||
10. Backend reiniciado no meio de uma visita: escalonamento acontece mesmo assim
|
||||
|
||||
O cenário 10 é o que valida o padrão de timeouts persistidos. O 5 valida a busca cega.
|
||||
|
||||
### 2. Teste de carga
|
||||
|
||||
**SFU:** 50 chamadas simultâneas de 2 participantes, medindo banda, CPU e tempo até o primeiro frame. Confronte com o dimensionamento de `05-INFRA-DOCKER.md` §4.
|
||||
|
||||
**API:** 100 visitas/minuto sustentadas — muito acima do real (~0,3 req/s), de propósito.
|
||||
|
||||
**Fila:** 10 mil jobs agendados para o mesmo instante, verificando que nenhum é perdido ou executado duas vezes.
|
||||
|
||||
**Banco:** tabela de auditoria com 1 milhão de visitas; a tela do admin precisa continuar fluida.
|
||||
|
||||
### 3. Injeção de falhas
|
||||
|
||||
Derrube cada peça, uma por vez, com uma visita em andamento, e verifique o comportamento:
|
||||
|
||||
| Peça derrubada | Comportamento esperado |
|
||||
|---|---|
|
||||
| LiveKit | Degrada para áudio → foto+texto → operador |
|
||||
| Redis | WebSocket degrada para polling; fila continua (está no Postgres) |
|
||||
| MinIO | Visita prossegue sem foto, com aviso; upload entra em retry |
|
||||
| FCM | WebSocket assume se o app está aberto; falha vai para o dashboard |
|
||||
| Uma réplica do backend | Outra assume; jobs pendentes executam |
|
||||
| Postgres (breve) | Erro claro ao visitante e contingência física acionada |
|
||||
|
||||
**Teste do relógio:** atrase o relógio de uma réplica em 30s e confirme que ShedLock não permite execução dupla.
|
||||
|
||||
### 4. Revisão de segurança
|
||||
|
||||
Percorra o modelo de ameaças de `06-LGPD-E-SEGURANCA.md` §5 e prove cada mitigação:
|
||||
|
||||
- [ ] **A.1 Busca cega** — tempo de resposta constante medido estatisticamente, não no olho. Este é o teste mais importante da fase
|
||||
- [ ] **A.2 Geofence** — rejeita fora do raio; GPS falsificado é registrado
|
||||
- [ ] **A.3 DoS social** — rate limit e quiet hours funcionam; varredura gera alerta
|
||||
- [ ] **A.4 Mídia** — nenhuma URL pública; assinada expira; acesso auditado
|
||||
- [ ] **A.5 Auditoria** — `UPDATE`/`DELETE` em `audit_log` falham no banco
|
||||
- [ ] **A.6 Tokens** — JWT de visitante não serve para outra visita nem outra portaria
|
||||
- [ ] **A.7 QR** — incrementar `qr_version` invalida os códigos antigos
|
||||
|
||||
Rode também: `pnpm audit`, `gradle dependencyCheck`, varredura de imagem Docker (Trivy), e headers verificados por observatório de segurança.
|
||||
|
||||
**Grep obrigatório:** nenhuma biblioteca de detecção ou reconhecimento facial em `build.gradle.kts` ou `package.json`. A tentação aparece disfarçada de UX ("só para enquadrar o rosto") e tem consequência jurídica direta.
|
||||
|
||||
### 5. Checklist de produção
|
||||
|
||||
**Infraestrutura**
|
||||
- [ ] Faixa UDP 50000–60000 aberta (esquecer isso conecta a chamada e nunca mostra vídeo)
|
||||
- [ ] TLS válido e renovação automática testada
|
||||
- [ ] Backup automático com **restore testado e cronometrado**
|
||||
- [ ] Segredos fora do repositório e fora das imagens
|
||||
- [ ] Healthchecks respondendo em todos os serviços
|
||||
- [ ] Alertas chegando em canal monitorado de verdade
|
||||
|
||||
**Aplicação**
|
||||
- [ ] Perfil `prod`, sem stack trace exposto
|
||||
- [ ] Rate limits ativos
|
||||
- [ ] CORS restrito aos domínios reais
|
||||
- [ ] Logs sem dado pessoal (nome, foto, coordenada) — **verificar linha a linha**
|
||||
- [ ] Migrations aplicadas em ordem em banco limpo
|
||||
|
||||
**Apps**
|
||||
- [ ] Builds de release assinados
|
||||
- [ ] Testados em dispositivo físico, Android e iOS
|
||||
- [ ] Ficha de privacidade da App Store e do Google Play preenchida
|
||||
- [ ] Justificativa de VoIP documentada para a App Review
|
||||
- [ ] Crash reporting **sem PII**
|
||||
|
||||
**Conformidade** — checklist completo de `06-LGPD-E-SEGURANCA.md` §9.
|
||||
|
||||
**Campo** — checklist de ativação de `07-REQUISITOS-DE-CAMPO.md` §2 para o condomínio piloto.
|
||||
|
||||
### 6. Deploy
|
||||
|
||||
`docs/anexos/deploy.md` com provisionamento, primeiro deploy, atualização sem downtime (rolling com healthcheck), rollback, aplicação de migration com dado em produção, e o procedimento de crescer para HA.
|
||||
|
||||
### 7. Piloto assistido
|
||||
|
||||
Antes da ativação definitiva, **duas semanas com o módulo de operador ligado**, monitoramento diário e reunião semanal com o síndico.
|
||||
|
||||
O que se mede: taxa real de atendimento, tempo médio de resolução, abandono de entregador, incidentes, e feedback qualitativo de moradores e entregadores.
|
||||
|
||||
**É esse número — não a expectativa comercial — que define se o condomínio fica no plano Autônomo ou no Assistido.**
|
||||
|
||||
## Critérios de aceite
|
||||
|
||||
- [ ] Os 10 cenários E2E passam em CI
|
||||
- [ ] Carga do SFU dentro do dimensionamento previsto
|
||||
- [ ] Toda injeção de falha se comporta como na tabela
|
||||
- [ ] Todas as mitigações do modelo de ameaças verificadas
|
||||
- [ ] Busca cega com tempo constante comprovado estatisticamente
|
||||
- [ ] Checklist de produção 100% marcado
|
||||
- [ ] Restore de backup executado e cronometrado
|
||||
- [ ] Runbooks testados por alguém que não os escreveu
|
||||
- [ ] Piloto de duas semanas concluído com métricas coletadas
|
||||
|
||||
## Não faça nesta fase
|
||||
|
||||
- Novas funcionalidades — se aparecer necessidade, vira v2
|
||||
- Otimização sem métrica que a justifique
|
||||
Reference in New Issue
Block a user