prompt inicial do projeto
This commit is contained in:
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 |
|
||||
Reference in New Issue
Block a user