From 6fb920d333f5dac7a41544b32dc15ba43c9f146bf1e30f3cd41ed69468f89db1 Mon Sep 17 00:00:00 2001 From: victor Date: Wed, 22 Jul 2026 15:55:55 -0300 Subject: [PATCH] prompt inicial do projeto --- README.md | 69 +++++ docs/00-VISAO-E-PRODUTO.md | 116 +++++++++ docs/01-ARQUITETURA.md | 240 ++++++++++++++++++ docs/02-MODELO-DE-DADOS.md | 403 ++++++++++++++++++++++++++++++ docs/03-FLUXOS-E-CONTRATOS.md | 277 ++++++++++++++++++++ docs/04-DESIGN-SYSTEM-UX.md | 186 ++++++++++++++ docs/05-INFRA-DOCKER.md | 292 ++++++++++++++++++++++ docs/06-LGPD-E-SEGURANCA.md | 196 +++++++++++++++ docs/07-REQUISITOS-DE-CAMPO.md | 150 +++++++++++ prompts/FASE-0-bootstrap.md | 79 ++++++ prompts/FASE-1-dominio-e-dados.md | 99 ++++++++ prompts/FASE-2-backend-api.md | 107 ++++++++ prompts/FASE-3-web-visitante.md | 90 +++++++ prompts/FASE-4-chamada.md | 93 +++++++ prompts/FASE-5-app-morador.md | 91 +++++++ prompts/FASE-6-painel-admin.md | 106 ++++++++ prompts/FASE-7-escalonamento.md | 91 +++++++ prompts/FASE-8-planos-e-lgpd.md | 126 ++++++++++ prompts/FASE-9-hardening.md | 126 ++++++++++ 19 files changed, 2937 insertions(+) create mode 100644 README.md create mode 100644 docs/00-VISAO-E-PRODUTO.md create mode 100644 docs/01-ARQUITETURA.md create mode 100644 docs/02-MODELO-DE-DADOS.md create mode 100644 docs/03-FLUXOS-E-CONTRATOS.md create mode 100644 docs/04-DESIGN-SYSTEM-UX.md create mode 100644 docs/05-INFRA-DOCKER.md create mode 100644 docs/06-LGPD-E-SEGURANCA.md create mode 100644 docs/07-REQUISITOS-DE-CAMPO.md create mode 100644 prompts/FASE-0-bootstrap.md create mode 100644 prompts/FASE-1-dominio-e-dados.md create mode 100644 prompts/FASE-2-backend-api.md create mode 100644 prompts/FASE-3-web-visitante.md create mode 100644 prompts/FASE-4-chamada.md create mode 100644 prompts/FASE-5-app-morador.md create mode 100644 prompts/FASE-6-painel-admin.md create mode 100644 prompts/FASE-7-escalonamento.md create mode 100644 prompts/FASE-8-planos-e-lgpd.md create mode 100644 prompts/FASE-9-hardening.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..b7b4684 --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# Projeto Portaria + +Portaria virtual para prédios residenciais e comerciais. O visitante escaneia um QR na entrada, o morador valida por videochamada e autoriza a entrada — com operador humano de retaguarda para quando ninguém atende. + +**Este repositório contém, por enquanto, a especificação e os prompts de implementação.** Nenhum código de aplicação foi escrito ainda. + +--- + +## Como usar + +Leia `docs/` na ordem, depois execute `prompts/` fase por fase. Cada prompt referencia as seções dos documentos que precisa respeitar — os documentos são a fonte de verdade, os prompts são o roteiro. + +**Não pule fases e não junte duas num prompt só.** O fatiamento existe porque as áreas de maior risco (WebRTC, notificação de chamada no iOS, LGPD) são exatamente as que um prompt monolítico implementa pela metade. + +## Documentos + +| Documento | Conteúdo | +|---|---| +| [`00-VISAO-E-PRODUTO`](docs/00-VISAO-E-PRODUTO.md) | Problema, personas, jornadas, planos comerciais, escopo, métricas | +| [`01-ARQUITETURA`](docs/01-ARQUITETURA.md) | **Espinha.** Stack, monorepo, os 7 padrões de robustez, ADRs | +| [`02-MODELO-DE-DADOS`](docs/02-MODELO-DE-DADOS.md) | Schema Postgres, outbox, fila, retenção, RLS preparada | +| [`03-FLUXOS-E-CONTRATOS`](docs/03-FLUXOS-E-CONTRATOS.md) | Máquinas de estado, REST, WebSocket, idempotência | +| [`04-DESIGN-SYSTEM-UX`](docs/04-DESIGN-SYSTEM-UX.md) | Tokens, telas, movimento, acessibilidade | +| [`05-INFRA-DOCKER`](docs/05-INFRA-DOCKER.md) | Compose, LiveKit, portas, banda, observabilidade, CI/CD | +| [`06-LGPD-E-SEGURANCA`](docs/06-LGPD-E-SEGURANCA.md) | Bases legais, modelo de ameaças, direitos do titular | +| [`07-REQUISITOS-DE-CAMPO`](docs/07-REQUISITOS-DE-CAMPO.md) | **Pré-requisitos contratuais de instalação**, não backlog | + +## Fases + +| Fase | Entrega | +|---|---| +| [0 — Bootstrap](prompts/FASE-0-bootstrap.md) | Monorepo, Docker, CI verde | +| [1 — Domínio e dados](prompts/FASE-1-dominio-e-dados.md) | `shared/` KMP, schema, outbox, fila, auth | +| [2 — API](prompts/FASE-2-backend-api.md) | REST, WebSocket, mídia, ciclo de vida da visita | +| [3 — Web do visitante](prompts/FASE-3-web-visitante.md) | PWA, ambos os funis, espera narrada | +| [4 — Chamada](prompts/FASE-4-chamada.md) | LiveKit, tokens, degradação graciosa | +| [5 — App do morador](prompts/FASE-5-app-morador.md) | Compose MP, FCM, PushKit/CallKit | +| [6 — Painel admin](prompts/FASE-6-painel-admin.md) | Auditoria, gestão, reconciliação | +| [7 — Escalonamento](prompts/FASE-7-escalonamento.md) | Fila de operador, app do operador, recado | +| [8 — Planos e LGPD](prompts/FASE-8-planos-e-lgpd.md) | Feature flags, retenção, direitos, SLOs | +| [9 — Hardening](prompts/FASE-9-hardening.md) | E2E, carga, falhas, segurança, piloto | + +## Stack + +**Backend** Spring Boot 3 + Kotlin · PostgreSQL 17 + PostGIS · jOOQ · Flyway +**Apps** Compose Multiplatform (Android + iOS) · **Web** Vite + React + TS +**Compartilhado** Kotlin Multiplatform (DTOs + máquina de estados) +**Mídia** LiveKit self-hosted + coturn · **Objetos** MinIO/S3 +**Infra** Docker Compose + Traefik · OpenTelemetry + Prometheus + Grafana + Loki + +## As decisões que mais importam + +**O modelo é híbrido, não autônomo.** O morador é chamado primeiro — é a tese do produto. Mas a taxa de atendimento nunca é 100%, e o mercado consolidado usa operador humano 24h justamente por isso. Por isso vendemos dois planos, com o operador como retaguarda paga. + +**Entrega tem funil próprio.** É a maioria do volume, e o entregador não espera videochamada. Foto do pacote, um toque, meta de 10s. + +**A busca de unidade é cega.** A API nunca confirma se uma unidade existe — nem pelo corpo da resposta, nem pelo tempo. Sem isso, qualquer pessoa com o QR mapeia quem mora onde. + +**A base legal é legítimo interesse, não consentimento.** Consentimento sob "aceite ou não entre" não é livre, e portanto não é válido. O que se faz é informar com clareza e arquivar a LIA. + +**Sem reconhecimento facial.** Elevaria os dados a sensíveis, sem base legal disponível. Decisão jurídica, não limitação técnica. + +**iOS exige PushKit + CallKit.** Notificação comum não faz o telefone tocar como chamada. Sem isso, o app não funciona como portaria. + +**Wi-Fi e botão físico são contratuais.** Hall blindado não pega 4G, e nem todo visitante tem smartphone com bateria. Nenhum código resolve isso. + +## Primeiro passo + +Antes da FASE 4, derrube o maior risco técnico: suba `livekit-server` + `coturn` e feche uma chamada entre um navegador móvel em 4G real e um Android, medindo o tempo até o primeiro frame. É barato de testar e caro de descobrir tarde. diff --git a/docs/00-VISAO-E-PRODUTO.md b/docs/00-VISAO-E-PRODUTO.md new file mode 100644 index 0000000..32dc2e9 --- /dev/null +++ b/docs/00-VISAO-E-PRODUTO.md @@ -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/) diff --git a/docs/01-ARQUITETURA.md b/docs/01-ARQUITETURA.md new file mode 100644 index 0000000..249403b --- /dev/null +++ b/docs/01-ARQUITETURA.md @@ -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> = 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 diff --git a/docs/02-MODELO-DE-DADOS.md b/docs/02-MODELO-DE-DADOS.md new file mode 100644 index 0000000..a3b8c5c --- /dev/null +++ b/docs/02-MODELO-DE-DADOS.md @@ -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`. diff --git a/docs/03-FLUXOS-E-CONTRATOS.md b/docs/03-FLUXOS-E-CONTRATOS.md new file mode 100644 index 0000000..c0a6a1b --- /dev/null +++ b/docs/03-FLUXOS-E-CONTRATOS.md @@ -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)`. diff --git a/docs/04-DESIGN-SYSTEM-UX.md b/docs/04-DESIGN-SYSTEM-UX.md new file mode 100644 index 0000000..22f8997 --- /dev/null +++ b/docs/04-DESIGN-SYSTEM-UX.md @@ -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 é. diff --git a/docs/05-INFRA-DOCKER.md b/docs/05-INFRA-DOCKER.md new file mode 100644 index 0000000..8b4dd60 --- /dev/null +++ b/docs/05-INFRA-DOCKER.md @@ -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. diff --git a/docs/06-LGPD-E-SEGURANCA.md b/docs/06-LGPD-E-SEGURANCA.md new file mode 100644 index 0000000..976d3b4 --- /dev/null +++ b/docs/06-LGPD-E-SEGURANCA.md @@ -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/) diff --git a/docs/07-REQUISITOS-DE-CAMPO.md b/docs/07-REQUISITOS-DE-CAMPO.md new file mode 100644 index 0000000..52a527b --- /dev/null +++ b/docs/07-REQUISITOS-DE-CAMPO.md @@ -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-` +- [ ] **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: + +3. Fora do horário, segurança 24h: + +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 | diff --git a/prompts/FASE-0-bootstrap.md b/prompts/FASE-0-bootstrap.md new file mode 100644 index 0000000..a239fa3 --- /dev/null +++ b/prompts/FASE-0-bootstrap.md @@ -0,0 +1,79 @@ +# FASE 0 — Bootstrap do monorepo + +## Objetivo + +Estrutura completa do monorepo, ambiente Docker subindo, CI verde. Nenhuma regra de negócio ainda — apenas o esqueleto onde tudo será construído. + +## Pré-requisitos + +Ler `docs/01-ARQUITETURA.md` (§3 estrutura do monorepo) e `docs/05-INFRA-DOCKER.md` (compose completo). + +## Tarefas + +### 1. Raiz do Gradle + +`settings.gradle.kts` incluindo `:shared` e `:backend`. Version catalog em `gradle/libs.versions.toml` com: Kotlin 2.1+, Spring Boot 3.4+, jOOQ, Flyway, PostGIS driver, Testcontainers, ShedLock, Resilience4j, LiveKit server SDK, kotlinx.serialization, kotlinx.datetime. + +JDK 21. Toolchain configurado explicitamente. + +### 2. Módulo `shared/` + +KMP com targets `jvm()`, `androidTarget()`, `iosArm64()`, `iosSimulatorArm64()`. Pacote base `br.com.portaria.shared`. Apenas kotlinx.serialization e kotlinx.datetime como dependências — **este módulo não conhece Spring, Android nem iOS**. + +Pacotes vazios criados: `model/`, `dto/`, `state/`, `validation/`. + +### 3. Módulo `backend/` + +Spring Boot 3 + Kotlin, estrutura hexagonal conforme `01-ARQUITETURA.md` §3. Perfis `local`, `test`, `prod`. Actuator com `health`, `prometheus`, `info` expostos. OpenAPI via springdoc em `/v3/api-docs`. + +Um controller `GET /api/v1/health` devolvendo `{"status":"UP"}`, só para provar a esteira ponta a ponta. + +### 4. `web/` com pnpm workspaces + +`pnpm-workspace.yaml` com `visitor`, `admin`, `shared-ui`. Vite + React 19 + TS em ambos os apps. `visitor` configurado como PWA. + +`shared-ui` exporta `tokens.json` e o `tokens.css` gerado. Script `pnpm tokens:build` que gera `tokens.css` e `Theme.kt` a partir do JSON — ver `docs/04-DESIGN-SYSTEM-UX.md` §2. + +### 5. `apps/` Compose Multiplatform + +`composeApp` (comum), `androidApp`, `iosApp`. Depende de `:shared`. Uma tela "Hello Portaria" em ambas as plataformas, apenas para validar o build. + +### 6. `infra/` + +`docker-compose.yml` exatamente como em `docs/05-INFRA-DOCKER.md` §2, mais `docker-compose.observability.yml` e `.env.example`. Configs em `livekit/livekit.yaml`, `traefik/`, `postgres/init/01-extensions.sql` (`CREATE EXTENSION postgis; CREATE EXTENSION pgcrypto;`). + +### 7. CI — GitHub Actions + +``` +.github/workflows/ci.yml + ├─ gradle build (backend + shared, com Testcontainers) + ├─ pnpm lint && test && build + ├─ gate de bundle: visitor > 200KB gzip → FALHA + ├─ docker compose config + └─ verificação de contraste dos tokens +``` + +O gate de bundle não é opcional — a escolha de React em vez de Wasm foi motivada por TTI, e sem gate automático essa vantagem se perde em poucas sprints. + +### 8. Documentação de arranque + +`README.md` na raiz com pré-requisitos, `docker compose up`, como rodar cada app, e o mapa de portas. + +## Critérios de aceite + +- [ ] `./gradlew build` verde +- [ ] `pnpm -r build` verde +- [ ] `docker compose -f infra/docker-compose.yml config` sem erro +- [ ] `docker compose up -d` sobe tudo; todos os healthchecks passam +- [ ] `GET /api/v1/health` responde via Traefik com TLS +- [ ] `/actuator/prometheus` expõe métricas +- [ ] App Android e iOS compilam e abrem a tela de teste +- [ ] `pnpm tokens:build` gera `tokens.css` e `Theme.kt` +- [ ] CI verde no primeiro push + +## Não faça nesta fase + +- Nenhuma tabela de negócio (é a FASE 1) +- Nenhum endpoint além do health +- Nenhuma tela real +- Nenhuma integração com LiveKit, FCM ou APNs diff --git a/prompts/FASE-1-dominio-e-dados.md b/prompts/FASE-1-dominio-e-dados.md new file mode 100644 index 0000000..7f3c979 --- /dev/null +++ b/prompts/FASE-1-dominio-e-dados.md @@ -0,0 +1,99 @@ +# FASE 1 — Domínio compartilhado e camada de dados + +## Objetivo + +Modelo de domínio em `shared/`, schema completo no Postgres, infraestrutura de confiabilidade (outbox, fila, idempotência) funcionando, e autenticação. Ainda sem endpoints de negócio. + +## Pré-requisitos + +FASE 0 concluída. Ler `docs/02-MODELO-DE-DADOS.md` inteiro e `docs/01-ARQUITETURA.md` §4 e §5. + +## Tarefas + +### 1. Domínio em `shared/` + +`state/VisitState.kt` e `VisitStateMachine.kt` conforme `01-ARQUITETURA.md` §4 e o diagrama de `03-FLUXOS-E-CONTRATOS.md` §1. A máquina de estados é a peça mais importante deste módulo — **teste toda transição válida e inválida**. + +`model/` — enums e value objects: `VisitKind`, `DeliveryRule`, `MemberRole`, `MediaKind`, `DegradedMode`, `FeatureKey`, `GeoPoint`. + +`dto/` — DTOs `@Serializable` de todos os contratos de `03-FLUXOS-E-CONTRATOS.md` §6. + +`validation/` — validação de nome de visitante, telefone E.164, formato de unidade, tamanho de mídia. Usada no cliente e no servidor. + +### 2. Migrations Flyway + +As dez migrations de `02-MODELO-DE-DADOS.md` §11, exatamente na ordem indicada. Pontos que não podem ser esquecidos: + +- `tenant_id uuid NOT NULL` em **toda** tabela de negócio +- `version integer NOT NULL DEFAULT 0` onde há escrita concorrente +- Nenhum `ON DELETE CASCADE` em dado auditável +- Índice GIST em `gates.location` +- Índice parcial em `visits` para estados ativos +- `V8` revoga `UPDATE`/`DELETE` em `audit_log` para o usuário da aplicação +- `V9` cria as policies de RLS e as deixa **desativadas** + +### 3. jOOQ + +Geração de código a partir do schema migrado (Testcontainers no build, não banco local). Repositórios para os agregados principais. **Todo repositório recebe `tenantId` como parâmetro obrigatório** — sem default, sem opcional. É o que torna a ativação futura do multi-tenant um interruptor. + +### 4. Outbox transacional + +```kotlin +interface OutboxPublisher { + fun enfileirar(evento: DomainEvent) // participa da transação corrente +} +``` + +Publisher que lê `outbox_events` pendentes com `FOR UPDATE SKIP LOCKED`, despacha, marca `published_at`, e em falha aplica backoff exponencial (`next_attempt_at`) e grava `last_error`. Após 10 tentativas, alerta. + +**Teste obrigatório:** transação que falha após `enfileirar()` não pode deixar evento na tabela. É exatamente a garantia que o padrão existe para dar. + +### 5. Fila de jobs e ShedLock + +```kotlin +interface JobScheduler { + fun agendar(tipo: JobType, payload: JsonObject, quando: Instant): Long + fun cancelar(jobId: Long) +} +``` + +Poller `@Scheduled(fixedDelay = 1000)` + `@SchedulerLock`, consumindo com `FOR UPDATE SKIP LOCKED`. Handlers registrados por `JobType`. + +**Teste obrigatório com duas instâncias simultâneas:** cada job executa exatamente uma vez. + +### 6. Idempotência + +Filtro/interceptor que lê `Idempotency-Key`, consulta `idempotency_keys`, e: + +- chave nova → executa e grava a resposta +- chave repetida com mesmo `request_hash` → devolve a resposta original +- chave repetida com corpo diferente → `422 IDEMPOTENCY_KEY_REUSE` + +### 7. Segurança base + +Spring Security com três cadeias de filtro conforme `06-LGPD-E-SEGURANCA.md` §6: `/api/v1/visitor/**` (JWT efêmero), `/api/v1/app/**` (OIDC), `/api/v1/admin/**` (OIDC + MFA). + +Emissão e validação do JWT de visitante: 15 min, escopo de uma visita, atado a `gateId` e IP. + +### 8. Auditoria + +Aspecto que grava em `audit_log` toda mutação de entidade auditável, com `before`/`after` em `jsonb`. + +## Critérios de aceite + +- [ ] `./gradlew :shared:allTests` verde (JVM + iOS) +- [ ] Cobertura de 100% das transições da máquina de estados, válidas e inválidas +- [ ] `flyway migrate` do zero cria o schema completo +- [ ] jOOQ gera código a partir do schema migrado +- [ ] Teste: rollback de transação não deixa evento no outbox +- [ ] Teste: duas instâncias do poller executam cada job exatamente uma vez +- [ ] Teste: `UPDATE` em `audit_log` falha por permissão do banco +- [ ] Teste: chave de idempotência repetida devolve a resposta original +- [ ] Teste: repositório sem `tenantId` não compila + +## Não faça nesta fase + +- Endpoints de negócio (FASE 2) +- Integração com LiveKit, FCM ou APNs +- Qualquer tela +- Ativar RLS diff --git a/prompts/FASE-2-backend-api.md b/prompts/FASE-2-backend-api.md new file mode 100644 index 0000000..22970e3 --- /dev/null +++ b/prompts/FASE-2-backend-api.md @@ -0,0 +1,107 @@ +# FASE 2 — API do backend + +## Objetivo + +Todos os endpoints REST, WebSocket de sinalização, armazenamento de mídia e o ciclo de vida da visita funcionando ponta a ponta — verificável por teste de integração, ainda sem interface. + +## Pré-requisitos + +FASES 0 e 1 concluídas. Ler `docs/03-FLUXOS-E-CONTRATOS.md` inteiro e `docs/06-LGPD-E-SEGURANCA.md` §5. + +## Tarefas + +### 1. Validação de QR e sessão de visitante + +`GET /api/v1/visitor/gates/{gateId}/preview?s={sig}` — valida assinatura HMAC com `gates.qr_secret` e confere `qr_version`. Devolve nome do condomínio e o aviso de tratamento vigente. Sem autenticação. + +`POST /api/v1/visitor/sessions` — recebe coordenadas, valida geofence com `ST_DWithin(gates.location, ponto, geofence_meters)`, registra `privacy_notices` (versão do aviso, IP, user agent) e emite o JWT efêmero. + +Localização ausente ou negada → `403 LOCATION_REQUIRED`. Fora do raio → `403 OUTSIDE_GEOFENCE` com `detail` em português pronto para exibir. + +### 2. Criação de visita — busca cega + +`POST /api/v1/visitor/visits`, multipart (JSON + mídia). + +**Esta é a implementação mais sensível do sistema.** Requisitos, todos obrigatórios: + +- Resolve `unit_id` a partir de bloco + unidade; **se não existir, cria a visita com `unit_id = NULL`** e preserva `unit_input` +- Resposta **idêntica** — mesmo corpo, mesmo status, mesmos headers — nos dois casos +- **Tempo de resposta constante:** meça o caminho mais lento e aplique delay artificial no mais rápido. Sem isso o ataque vira timing attack e a busca cega não serve para nada +- Nome de morador nunca aparece na resposta +- Rate limit por IP e por dispositivo, com bloqueio progressivo + +Verifica `quiet_hours` do condomínio: dentro da janela, a visita pula o toque ao morador e vai direto para `FILA_OPERADOR` ou `RECADO_EM_VIDEO`. + +Enfileira `VisitaCriada` no outbox e agenda `VISIT_RING_TIMEOUT` (20s) e `VISIT_EXPIRE` (10 min). + +### 3. Ciclo de vida da visita + +Serviço de aplicação com as transições de `03-FLUXOS-E-CONTRATOS.md` §1, sempre validadas por `VisitStateMachine` e sempre com optimistic locking em `visits.version`. + +Handlers de job: + +| Job | Ação | +|---|---| +| `VISIT_RING_TIMEOUT` | → `ESCALONADA`; toca para os demais `unit_members` por `ring_order`; agenda `VISIT_ESCALATE` | +| `VISIT_ESCALATE` | → `FILA_OPERADOR` se `operator_queue` ativo, senão `RECADO_EM_VIDEO` | +| `VISIT_QUEUE_TIMEOUT` | → `RECADO_EM_VIDEO` | +| `VISIT_EXPIRE` | → `EXPIRADA` | +| `DELIVERY_DEFAULT_RULE` | aplica `units.delivery_rule` | + +`POST /api/v1/app/visits/{id}/resolve` exige `Idempotency-Key`. Conflito de versão devolve `409 VISIT_ALREADY_RESOLVED` com `resolvedBy` e `resolvedAt` — o app precisa mostrar "Maria já autorizou", não um erro técnico. + +### 4. Armazenamento de mídia + +Porta `StoragePort` com adaptador MinIO/S3. Ao receber mídia: + +- Valida `Content-Type` por **magic bytes**, nunca por extensão +- Limites: 8MB foto, 30MB vídeo +- **Remove EXIF** — carrega GPS próprio, que não é o dado que coletamos e não passou pelo aviso +- Calcula `sha256` e grava em `media_assets` +- Nome de objeto gerado pelo servidor +- Define `expires_at` conforme a retenção do tipo (`06-LGPD-E-SEGURANCA.md` §4) + +URL assinada de 5 min para leitura, **sempre** registrando acesso em `audit_log`. Nenhuma URL pública, em nenhuma hipótese. + +### 5. WebSocket + +`/ws/visitor/{visitId}` e `/ws/app`, com o envelope e os tipos de `03-FLUXOS-E-CONTRATOS.md` §7. Heartbeat de 20s. + +**O WebSocket nunca é fonte de verdade.** Toda mudança de estado é confirmada por REST ou push. Se o WS cair, o cliente faz polling — e a portaria continua funcionando. + +Com múltiplas réplicas do backend, a difusão de eventos usa Redis pub/sub (só transporte; a durabilidade fica no outbox). + +### 6. Endpoints de app e admin + +Todos os de `03-FLUXOS-E-CONTRATOS.md` §6, com autorização por método: + +```kotlin +@PreAuthorize("@access.canViewVisit(#visitId, authentication)") +``` + +Morador vê apenas visitas das suas unidades; operador, apenas as da fila; admin, apenas as do seu tenant. + +Importação CSV de unidades com **dry-run obrigatório** — devolve o que seria criado, alterado e rejeitado antes de gravar. + +### 7. Tratamento de erros + +`application/problem+json` (RFC 7807) com os `code` da tabela de `03-FLUXOS-E-CONTRATOS.md` §9. **`detail` sempre em português e pronto para exibir ao visitante** — a tela da portaria não é lugar para mensagem técnica. + +## Critérios de aceite + +- [ ] Testes de integração com Testcontainers cobrindo os fluxos A, B e C ponta a ponta +- [ ] **Teste de busca cega:** unidade existente e inexistente produzem resposta idêntica, e a diferença de tempo fica abaixo do ruído de medição +- [ ] Teste: geofence rejeita fora do raio e aceita dentro +- [ ] Teste: `quiet_hours` desvia do morador +- [ ] Teste: dois `resolve` concorrentes — um vence, outro recebe `409` com o autor +- [ ] Teste: `resolve` repetido com mesma `Idempotency-Key` não duplica +- [ ] Teste: escalonamento dispara após restart do backend (job persistido) +- [ ] Teste: EXIF removido da imagem armazenada +- [ ] Teste: acesso a mídia gera linha em `audit_log` +- [ ] OpenAPI completo em `/v3/api-docs` + +## Não faça nesta fase + +- Integração com LiveKit (FASE 4) — `room_name` fica como placeholder +- Push real (FASE 5) — `NotificationChannel` com implementação de log +- Qualquer interface diff --git a/prompts/FASE-3-web-visitante.md b/prompts/FASE-3-web-visitante.md new file mode 100644 index 0000000..efa58ea --- /dev/null +++ b/prompts/FASE-3-web-visitante.md @@ -0,0 +1,90 @@ +# FASE 3 — Web do visitante + +## Objetivo + +PWA do visitante completa: QR → aviso → permissões → captura → destino → espera narrada → resultado. Ambos os funis (visita e entrega). Sem videochamada ainda. + +## Pré-requisitos + +FASES 0–2 concluídas. Ler `docs/04-DESIGN-SYSTEM-UX.md` §3 e `docs/03-FLUXOS-E-CONTRATOS.md` §2 e §3. + +## Contexto que determina cada decisão + +O visitante está **em pé na calçada, no sol, com pressa, em 4G, e não vai instalar nada**. Tudo aqui deriva disso: bundle mínimo, uma ação por tela, tipografia grande, contraste alto. + +## Tarefas + +### 1. Base + +Vite + React 19 + TS em `web/visitor`. Roteamento por `/v/{gateId}?s={sig}`. Tokens de `shared-ui`. PWA com manifest, **sem service worker de cache agressivo** — o visitante usa uma vez e o cache velho causaria mais problema que benefício. + +**Orçamento: 200KB gzip**, com gate em CI. O SDK do LiveKit entra por `import()` dinâmico apenas na FASE 4, na tela de chamada — ele sozinho tem o tamanho de todo o resto do app. + +### 2. Telas + +Cinco telas conforme `04-DESIGN-SYSTEM-UX.md` §3, uma ação cada. + +**Tela 1 — Aviso de tratamento.** Não é modal de consentimento com checkbox. A base legal é legítimo interesse; o que se faz é **informar com clareza**. Texto de `06-LGPD-E-SEGURANCA.md` §1, com link "Saiba mais". O botão diz "Continuar", não "Aceito". + +**Tela 2 — Escolha do funil.** `📦 Entrega` primeiro, porque é a maioria do volume. `👤 Visita` abaixo. + +**Tela 3 — Permissões e captura.** Solicita câmera e localização, com explicação **antes** do prompt nativo — pedir permissão sem contexto é a principal causa de negativa. Captura foto do rosto (visita) ou do pacote (entrega). Comprime no cliente para no máximo 1600px no lado maior. + +Permissão negada: localização é bloqueante (`403 LOCATION_REQUIRED`), com instrução de como reabilitar. Câmera negada em entrega degrada para "sem foto"; em visita, é bloqueante. + +**Tela 4 — Destino.** Bloco e unidade, campos livres. **Nenhum autocomplete, nenhuma sugestão, nenhuma validação de existência** — é a busca cega, e um autocomplete a destruiria. Nome do visitante obrigatório; documento e telefone opcionais. + +**Tela 5 — Espera narrada.** A tela onde o produto se ganha ou se perde: + +``` +TOCANDO "Chamando o morador..." barra 0→20s +ESCALONADA "Tentando outros moradores..." barra 0→15s +FILA_OPERADOR "Transferindo para a portaria" + posição na fila +RECADO "Grave um recado em vídeo" → gravação +``` + +Nunca um spinner mudo. Um spinner de 35 segundos é indistinguível de um app quebrado. + +**Tela 6 — Resultado.** Autorizado: PIN de 6 dígitos em `display`, QR grande, contagem regressiva de validade. Negado: mensagem clara e sem tom acusatório. Expirado: instrução de contingência (botão físico da portaria). + +### 3. Estado e conexão + +Estado da visita via WebSocket com **fallback automático para polling** de 3s se o WS cair ou não conectar em 5s. O visitante em 4G instável é o caso comum, não a exceção. + +Reconexão com backoff exponencial e teto de 10s. Estado da sessão em `sessionStorage`, para que refresh acidental não perca a visita. + +### 4. Gravação de recado + +`MediaRecorder`, 30s máximo, preview antes de enviar, upload com barra de progresso e retry. + +### 5. Erros + +Todo erro exibido vem do `detail` do `problem+json`, em português e acionável: **"Aproxime-se da entrada e tente novamente"**, nunca `OUTSIDE_GEOFENCE`. + +### 6. Acessibilidade e legibilidade em campo + +- Alvos de toque ≥ 48×48px, sem exceção +- Corpo de texto ≥ 16px +- Contraste ≥ 4.5:1, verificado em CI +- Apenas extremos da escala neutra — sol direto derruba tons médios +- `prefers-reduced-motion` respeitado +- `lang="pt-BR"`, textos externalizados para i18n futura +- Funciona em Safari iOS 15+ e Chrome Android 100+ + +## Critérios de aceite + +- [ ] **Bundle ≤ 200KB gzip** (gate de CI) +- [ ] TTI < 2s em 4G simulado (Lighthouse throttling) +- [ ] Lighthouse: Performance ≥ 90, Acessibilidade = 100 +- [ ] Fluxo de entrega completo em ≤ 4 toques +- [ ] Tela 4 não tem autocomplete nem validação de unidade +- [ ] WS derrubado à força → polling assume e o fluxo continua +- [ ] Localização negada → mensagem acionável, não erro técnico +- [ ] Testado em iPhone e Android reais, sob sol +- [ ] Todos os textos de erro em português, sem código técnico + +## Não faça nesta fase + +- Videochamada (FASE 4) +- Service worker com cache de assets versionados +- Qualquer tela de admin diff --git a/prompts/FASE-4-chamada.md b/prompts/FASE-4-chamada.md new file mode 100644 index 0000000..e6a0312 --- /dev/null +++ b/prompts/FASE-4-chamada.md @@ -0,0 +1,93 @@ +# FASE 4 — Videochamada + +## Objetivo + +LiveKit integrado ponta a ponta: emissão de tokens, sala por visita, vídeo no navegador do visitante, e degradação graciosa quando o SFU falha. + +## Pré-requisitos + +FASES 0–3 concluídas. Ler `docs/05-INFRA-DOCKER.md` §3 e §4, e `docs/01-ARQUITETURA.md` §5.5. + +> **Faça a POC antes de escrever código de produção.** Suba `livekit-server` + `coturn` e feche uma chamada entre um navegador móvel em 4G real e um Android, medindo o tempo até o primeiro frame. É o maior risco técnico do projeto e o mais barato de derrubar cedo. Se a POC falhar, o problema quase sempre é a faixa UDP fechada no firewall. + +## Tarefas + +### 1. Porta de vídeo + +```kotlin +interface VideoCallProvider { + suspend fun criarSala(visitId: UUID): RoomInfo + suspend fun emitirToken(visitId: UUID, participante: Participant): String + suspend fun encerrarSala(visitId: UUID) + suspend fun saudavel(): Boolean +} +``` + +Adaptador `LiveKitProvider`. A porta existe para permitir trocar por serviço gerenciado sem tocar nos apps — decisão registrada em `01-ARQUITETURA.md` ADR-003. + +### 2. Emissão de tokens + +Token JWT do LiveKit gerado **pelo backend**, nunca pelo cliente. Escopo mínimo: + +- `roomJoin` apenas na sala daquela visita +- `canPublish` para vídeo e áudio +- `canPublishData: false` +- TTL de 10 minutos, alinhado à expiração da visita + +`room_name` derivado determinísticamente de `visitId`, gravado em `visits.room_name`. + +**Um token nunca dá acesso a outra sala.** Verifique isso com teste explícito. + +### 3. Ciclo de vida da sala + +Sala criada na transição para `EM_CHAMADA`, não antes — criar em `PENDENTE` desperdiça recurso do SFU em visitas que nunca são atendidas. + +Encerrada ao chegar em estado final. `empty_timeout: 120` no LiveKit é a rede de segurança para sala órfã. + +### 4. Vídeo na web do visitante + +SDK `livekit-client` carregado por **`import()` dinâmico**, apenas quando a visita entra em `EM_CHAMADA`. Ele sozinho pesa mais que todo o resto do app — carregá-lo no bundle inicial destruiria o orçamento de 200KB da FASE 3. + +Vídeo remoto em tela cheia, auto-preview pequeno no canto, botão de mudo e de encerrar. Indicador de qualidade de conexão. + +### 5. Degradação graciosa + +Circuit breaker (Resilience4j) sobre o `VideoCallProvider`. Ao abrir: + +``` +LiveKit fora ─► AUDIO (mesma sala, sem vídeo, muito menos banda) + └─► FOTO_TEXTO (visitante manda foto, morador aprova sem chamada) + └─► FILA_OPERADOR (resolução por telefone) +``` + +O nível vigente vai em `visits.degraded_mode`, é emitido como `DEGRADED_MODE` no WebSocket, aparece em `/actuator/health` e no painel admin. + +**Comunique a degradação, nunca a esconda.** Esconder faz o usuário achar que o produto quebrou; nomear faz o produto parecer resiliente — que é o que ele é. + +### 6. Configuração de mídia + +**Limite de resolução imposto no servidor**, não no cliente: 480p a ~600kbps. 480p basta para reconhecer alguém na portaria; 1080p multiplicaria o custo de banda por quatro sem melhorar nenhuma decisão. Ver o dimensionamento em `05-INFRA-DOCKER.md` §4. + +Simulcast desligado (só dois participantes). `adaptiveStream` e `dynacast` ligados. TURN configurado com o segredo do coturn. + +### 7. Métricas + +`portaria.call.time_to_first_frame` · `portaria.call.duration` · `portaria.call.failed_ratio` · `portaria.call.turn_usage_ratio` (uso alto de TURN indica problema de rede na portaria) · `portaria.call.degraded_ratio`. + +## Critérios de aceite + +- [ ] POC validada em rede real antes do código de produção +- [ ] Chamada completa entre navegador móvel em 4G e Android +- [ ] Tempo até o primeiro frame < 3s em rede boa +- [ ] Teste: token de uma sala é rejeitado em outra sala +- [ ] Teste: com LiveKit derrubado, o sistema degrada para áudio e depois para foto+texto +- [ ] Teste: sala encerrada em todos os estados finais +- [ ] SDK do LiveKit **não** aparece no bundle inicial do visitante +- [ ] Bundle inicial continua ≤ 200KB gzip +- [ ] Chamada funciona atrás de NAT simétrico (valida o coturn) + +## Não faça nesta fase + +- Gravação (módulo `video_recording`, FASE 8) +- App do morador (FASE 5) +- Mais de 2 participantes por sala diff --git a/prompts/FASE-5-app-morador.md b/prompts/FASE-5-app-morador.md new file mode 100644 index 0000000..c959a34 --- /dev/null +++ b/prompts/FASE-5-app-morador.md @@ -0,0 +1,91 @@ +# FASE 5 — App do morador + +## Objetivo + +App Compose Multiplatform (Android + iOS) que **toca de verdade** com o app fechado, permite resolver entrega em um toque na notificação, e atende videochamada. + +## Pré-requisitos + +FASES 0–4 concluídas. Ler `docs/04-DESIGN-SYSTEM-UX.md` §4. + +## O ponto que define esta fase + +**No iOS, notificação comum não faz o telefone tocar como chamada.** Só PushKit + CallKit fazem. Sem isso o app não funciona como portaria — o morador vê a chamada quando abrir o celular, dez minutos depois, e o visitante já foi embora. + +E a Apple **obriga**: ao receber um push do PushKit, o app precisa reportar a chamada ao CallKit imediatamente, na mesma execução. Não reportar faz o sistema matar o app e, com reincidência, revogar o direito de receber VoIP push. Não há meio-termo aqui. + +## Tarefas + +### 1. Estrutura + +`apps/composeApp` com Compose Multiplatform, dependendo de `:shared`. Arquitetura MVVM com `ViewModel` compartilhado. Ktor Client para HTTP e WebSocket. Material 3 Expressive com o `Theme.kt` gerado dos tokens. + +Um único app com dois perfis (morador e operador), decididos por `GET /me`. + +### 2. Notificação de chamada — Android + +FCM com prioridade `high`. Notificação `CallStyle` com full-screen intent para visita; notificação com ações para entrega. + +Permissões necessárias: `POST_NOTIFICATIONS` (13+), `USE_FULL_SCREEN_INTENT` (14+), `FOREGROUND_SERVICE_MICROPHONE` e `FOREGROUND_SERVICE_CAMERA`. + +**Otimização de bateria é o inimigo silencioso.** Xiaomi, Samsung e Huawei matam apps agressivamente e o push nunca chega. O app precisa detectar e orientar o usuário a isentá-lo — e o painel admin precisa mostrar quem está com push falhando (`05-INFRA-DOCKER.md` §6). + +### 3. Notificação de chamada — iOS + +**Visita** → PushKit VoIP push → `CXProvider.reportNewIncomingCall()` **imediatamente**, antes de qualquer chamada de rede. Só depois busque os detalhes da visita. + +**Entrega** → notificação comum com `UNNotificationCategory` e ações. CallKit para uma entrega seria intrusivo e é risco de reprovação na App Review. + +Entitlement de VoIP configurado. Chave APNs `.p8` no backend. Áudio configurado via `AVAudioSession` com categoria `playAndRecord`. + +**Nota de App Review:** documente na submissão que o app é VoIP legítimo de controle de acesso. Descreva o fluxo. Apps que usam CallKit para notificação genérica são reprovados — este não é o caso, mas o revisor precisa entender por quê. + +### 4. Ações de um toque + +O morador resolve **sem abrir o app**: + +- Android: `Notification.Action` com `PendingIntent` para um `BroadcastReceiver` +- iOS: `UNNotificationAction` tratada na extensão + +Ambas chamam `POST /visits/{id}/resolve` com `Idempotency-Key` gerada no dispositivo. **Trate a falha de rede com retry**: a ação disparada em rede ruim que falha em silêncio é a pior falha possível deste app — o morador acha que autorizou e o entregador continua parado. + +### 5. Telas + +**Home** — pendências (recados não resolvidos) no topo, histórico abaixo, estado de conexão visível. + +**Chamada** — vídeo do visitante em tela cheia, auto-preview no canto. Sobreposto: nome informado, unidade de destino, e o **selo de geofence** (`✓ Na entrada` verde / `⚠ A 340m da portaria` âmbar). Esse selo é a informação de segurança mais útil da tela — alguém acionando de longe é sinal claro. Ações: `Autorizar` (verde, primária), `Negar`, mudo, encerrar. + +**Detalhe da visita** — foto, mapa do ponto, timeline de `visit_attempts`, player do recado. + +**Configurações** — regra de entrega por unidade, ordem de toque, dispositivos ativos, notificações. + +### 6. Registro de dispositivo + +`POST /devices` no login e a cada renovação de token, enviando `push_token` e — no iOS — também `voip_token`. Renove sempre que o sistema emitir novo token; token expirado é a causa número um de "não recebi a chamada". + +### 7. Concorrência entre moradores + +Ao receber `409 VISIT_ALREADY_RESOLVED`, mostre **"Maria já autorizou"** com horário. Nunca um erro técnico — dois moradores respondendo juntos é situação normal, não falha. + +### 8. Tempo real + +WebSocket enquanto o app está em foreground, com fallback para push. Ao voltar do background, reconcilia estado via REST antes de confiar no WS. + +## Critérios de aceite + +- [ ] **iOS: chamada toca em tela cheia com o app fechado e o telefone bloqueado** +- [ ] **Android: full-screen intent aparece com o app fechado e a tela apagada** +- [ ] Entrega resolvida pela notificação, sem abrir o app, em ambas as plataformas +- [ ] Teste com o app morto por otimização de bateria (Xiaomi/Samsung) +- [ ] Videochamada funciona com o app vindo do background +- [ ] `409` mostra quem já resolveu +- [ ] Ação em rede ruim tem retry e não falha em silêncio +- [ ] Registro de dispositivo renova o token corretamente +- [ ] Testado em dispositivos físicos — simulador não recebe push +- [ ] Acessibilidade: TalkBack e VoiceOver navegam a tela de chamada + +## Não faça nesta fase + +- App do operador completo (FASE 7) — perfil de operador fica oculto +- WhatsApp (FASE 8) +- Autorizações recorrentes (v2) diff --git a/prompts/FASE-6-painel-admin.md b/prompts/FASE-6-painel-admin.md new file mode 100644 index 0000000..6d406c8 --- /dev/null +++ b/prompts/FASE-6-painel-admin.md @@ -0,0 +1,106 @@ +# FASE 6 — Painel administrativo + +## Objetivo + +Painel web completo para síndico e administradora: gestão do condomínio, auditoria de visitas com mídia e mapa, importação de moradores, reconciliação e feature flags. + +## Pré-requisitos + +FASES 0–5 concluídas. Ler `docs/04-DESIGN-SYSTEM-UX.md` §6 e `docs/03-FLUXOS-E-CONTRATOS.md` §6. + +## Contexto + +Ao contrário da tela do visitante, aqui **densidade é virtude**: usuário recorrente, em desktop, que precisa de tabela grande, filtro fino e comparação. O orçamento de bundle não se aplica. + +## Tarefas + +### 1. Base + +Vite + React 19 + TS em `web/admin`. TanStack Router e TanStack Query. TanStack Table para grades virtualizadas. Leaflet para mapa (tiles OSM). Tokens de `shared-ui`. + +Login OIDC com **MFA obrigatório** — este perfil vê fotos de todos os visitantes e a localização de todos os acessos; é o alvo mais valioso do sistema. + +### 2. Auditoria de visitas — a tela mais importante + +Tabela virtualizada com filtros persistidos na URL: período, condomínio, bloco, unidade, tipo, estado, **dentro/fora do geofence**, e quem resolveu. + +Colunas: data/hora, tipo, visitante, unidade, estado, quem resolveu, duração, selo de geofence. + +**Detalhe da visita:** + +- Foto do visitante ou do pacote (URL assinada de 5 min) +- **Mapa** com o ponto do visitante e o raio do portão desenhado — é aqui que uma tentativa remota fica óbvia +- Timeline de `visit_attempts`: quem foi notificado, por qual canal, entregue ou não, quem atendeu. **É o que responde "por que ninguém atendeu?"** — a pergunta mais comum do síndico +- Player do recado, se houver +- Registro de exibição do aviso (`privacy_notices`) com a versão do texto +- Hash `sha256` da mídia, para valor probatório + +Exportação CSV e PDF do resultado filtrado, com marca d'água de quem exportou e quando. + +**Todo acesso a mídia gera linha em `audit_log`** — inclusive o do admin. Isso não é opcional. + +### 3. Gestão do condomínio + +CRUD de condomínios, blocos, unidades, pessoas, vínculos, portarias e responsáveis pela abertura. + +**Importação CSV de unidades e moradores com dry-run obrigatório**: mostra o que será criado, alterado e rejeitado, e só grava após confirmação. Importar uma base errada em cima de outra correta é dano difícil de reverter. + +Convite de morador por link, com acompanhamento (enviado / aberto / aceito). + +Configuração da portaria: posição no mapa, `geofence_meters` com o raio desenhado para calibragem visual, `qr_version` com botão de invalidar-e-reimprimir, e geração do PDF do QR pronto para impressão (fosco, 15×15cm, conforme `07-REQUISITOS-DE-CAMPO.md`). + +### 4. Reconciliação + +Tela dedicada ao relatório de `GET /admin/reconciliation`: + +- Moradores com `valid_until` vencido +- **Unidades sem nenhum dispositivo ativo** — estas nunca atendem +- Dispositivos com push falhando há mais de 7 dias (token morto = desinstalou) +- Unidades com taxa de atendimento abaixo de 30% no mês +- Convites nunca aceitos + +Cada item com ação direta: reenviar convite, marcar como saído, contatar. + +Esta tela é o que mantém o produto vivo depois do primeiro mês — base desatualizada quebra tudo silenciosamente. + +### 5. Configuração do app do morador + +O que o síndico controla no app dos moradores: regra padrão de entrega por unidade, `ring_order`, `quiet_hours` do condomínio, e quais funcionalidades aparecem. + +### 6. Planos e módulos + +Visualização do plano do tenant e toggles de `tenant_features`: `whatsapp_notifications`, `operator_queue`, `video_recording`, `access_control_hardware`, `recurring_authorizations`. + +Cada toggle mostra o efeito prático em uma frase — "Sem operador, visitas não atendidas viram recado em vídeo" — e registra a mudança em `audit_log` com autor. + +### 7. SLOs e saúde + +Dashboard de `GET /admin/slo` com as métricas de `00-VISAO-E-PRODUTO.md` §6: latência QR→toque, taxa de atendimento, latência de entrega, abandono. + +**Modo degradado vigente exibido com destaque.** O síndico precisa saber que o sistema está reduzido antes de receber a reclamação. + +### 8. LGPD + +Atendimento a titulares: busca por CPF ou telefone, exportação JSON, e eliminação — que apaga mídia e anonimiza nome **preservando a linha da visita** (`06-LGPD-E-SEGURANCA.md` §3). + +Cadastro do encarregado (DPO), exibido no aviso de privacidade. Editor do texto do aviso com **versionamento** — mudar o texto cria nova versão, e as visitas antigas continuam referenciando a versão que foi realmente exibida. + +## Critérios de aceite + +- [ ] Login exige MFA +- [ ] Tabela de auditoria fluida com 100 mil visitas (virtualização) +- [ ] Mapa mostra ponto do visitante e raio do portão +- [ ] Timeline de tentativas explica por que ninguém atendeu +- [ ] Acesso a mídia usa URL assinada e gera auditoria +- [ ] Importação CSV com dry-run funcional +- [ ] Relatório de reconciliação identifica unidades sem dispositivo ativo +- [ ] PDF do QR sai no tamanho e formato de `07-REQUISITOS-DE-CAMPO.md` +- [ ] Toggle de módulo registra autor em `audit_log` +- [ ] Eliminação LGPD preserva a linha anonimizada +- [ ] Acessibilidade: navegação completa por teclado, contraste ≥ 4.5:1 + +## Não faça nesta fase + +- Billing automatizado (v2) +- Gestão de tenants (multi-tenancy segue desativada) +- App do operador (FASE 7) diff --git a/prompts/FASE-7-escalonamento.md b/prompts/FASE-7-escalonamento.md new file mode 100644 index 0000000..8f0838b --- /dev/null +++ b/prompts/FASE-7-escalonamento.md @@ -0,0 +1,91 @@ +# FASE 7 — Escalonamento, fila de operador e recado + +## Objetivo + +Completar a retaguarda: escalonamento entre moradores, fila de operador humano com contexto, app do operador, e recado em vídeo. É o que transforma "chamada não atendida" em "visita resolvida". + +## Pré-requisitos + +FASES 0–6 concluídas. Ler `docs/03-FLUXOS-E-CONTRATOS.md` §4 e `docs/00-VISAO-E-PRODUTO.md` §1. + +## Por que esta fase existe + +A tese do produto é que o morador atende. A realidade é que ele nem sempre atende — gente em reunião, dirigindo, dormindo. É por isso que o mercado consolidado usa operador humano 24h. + +Esta fase é o que torna a aposta vendável: quando o morador não atende, o visitante **não fica sem saída**. E é o que sustenta comercialmente o plano Assistido. + +## Tarefas + +### 1. Escalonamento entre moradores + +`VISIT_RING_TIMEOUT` (20s) → `ESCALONADA`. Toca para os demais `unit_members` da unidade, **em paralelo**, respeitando `ring_order` para ordenar a exibição e `receives_calls`. + +O primeiro que atender vence; os demais recebem cancelamento da notificação — deixar notificação órfã tocando depois de resolvido é ruído que faz o morador desativar o app. + +### 2. Fila de operador + +Ativa apenas com `tenant_features.operator_queue` habilitado. Sem o módulo, `ESCALONADA` vai direto para `RECADO_EM_VIDEO`. + +Fila ordenada por tempo de espera, com prioridade para `VISITA` sobre `ENTREGA`. `POST /operator/queue/claim` usa `FOR UPDATE SKIP LOCKED` — dois operadores nunca pegam a mesma visita. + +Posição na fila enviada ao visitante por `QUEUE_POSITION` no WebSocket. Esperar é tolerável; esperar sem saber quanto falta, não. + +`VISIT_QUEUE_TIMEOUT` (120s) → `RECADO_EM_VIDEO`. + +### 3. App do operador + +Mesmo binário Compose Multiplatform, perfil decidido por `GET /me`. Layout mais denso que o do morador. + +**Fila** à esquerda: foto, tipo, unidade, tempo de espera e **por que escalonou** (ninguém atendeu / quiet hours / unidade inexistente). + +**Contexto** à direita, antes de assumir: histórico da unidade, visitas recentes, regra de entrega, nomes dos moradores, telefone. **O operador nunca atende sem contexto** — atender às cegas é o que faz a portaria remota parecer pior que a física. + +Presença: `DISPONIVEL` / `EM_ATENDIMENTO` / `OFFLINE`, com heartbeat. Operador que perde conexão volta para `OFFLINE` automaticamente e suas visitas retornam à fila. + +Ao resolver, o operador registra `resolution_reason` — obrigatório, e é o que alimenta a análise de por que o modelo autônomo falhou naquele caso. + +### 4. Recado em vídeo + +`RECADO_EM_VIDEO`: o visitante grava até 30s. Salvo em `media_assets` com `kind = RECADO_VIDEO` e retenção de 30 dias. + +Notificação ao morador **sem urgência** — não é CallKit, não é full-screen intent. É pendência, não chamada. + +Aparece no topo da home do app até ser resolvida. O morador pode autorizar retroativamente (gerando `access_grant` com validade estendida), negar, ou apenas marcar como visto. + +### 5. Quiet hours + +Dentro de `condominiums.quiet_hours`, visitas não pré-autorizadas **pulam o toque ao morador**: vão direto para `FILA_OPERADOR` (se o módulo estiver ativo) ou `RECADO_EM_VIDEO`. + +É a defesa contra tocar em todos os apartamentos de madrugada, e precisa ser óbvia para o visitante: "Fora do horário. Transferindo para a portaria." + +### 6. Cancelamento de notificações + +Ao resolver a visita, cancele as notificações pendentes em todos os dispositivos que foram acionados. Android usa `notificationId` estável por visita; iOS usa `CXProvider.reportCall(with:endedAt:reason:)`. + +Uma chamada CallKit que continua tocando depois de resolvida é bug grave de percepção — o morador acha que o app está quebrado. + +### 7. Métricas de escalonamento + +`portaria.escalation.ratio` (quantas visitas escalaram) · `portaria.queue.wait_time` · `portaria.queue.abandoned` · `portaria.message.recorded` · `portaria.message.unresolved_24h`. + +`escalation.ratio` é a métrica que decide se um condomínio fica no plano Autônomo ou precisa migrar para o Assistido. + +## Critérios de aceite + +- [ ] Escalonamento toca para os demais moradores após 20s +- [ ] Primeiro a atender vence; notificações dos outros são canceladas +- [ ] Sem `operator_queue`, escalonada vai direto para recado +- [ ] Dois operadores não pegam a mesma visita (teste concorrente) +- [ ] Posição na fila chega ao visitante em tempo real +- [ ] Operador que perde conexão devolve a visita à fila +- [ ] Recado gravado, armazenado e notificado sem urgência +- [ ] Recado aparece como pendência na home até resolver +- [ ] Quiet hours desvia sem tocar em ninguém, com aviso ao visitante +- [ ] CallKit não continua tocando após resolução +- [ ] Fluxo completo sem morador algum: QR → escalonamento → fila → operador → autorizado + +## Não faça nesta fase + +- Pré-autorização e recorrentes (v2) +- WhatsApp (FASE 8) +- Escalação para telefone/PSTN diff --git a/prompts/FASE-8-planos-e-lgpd.md b/prompts/FASE-8-planos-e-lgpd.md new file mode 100644 index 0000000..6f25320 --- /dev/null +++ b/prompts/FASE-8-planos-e-lgpd.md @@ -0,0 +1,126 @@ +# FASE 8 — Planos, módulos, LGPD e observabilidade + +## Objetivo + +Fechar o que torna o produto operável e vendável: módulos ligáveis por tenant, canal de WhatsApp preparado (interface, não integração), retenção e expurgo automáticos, direitos do titular, e os SLOs medidos de verdade. + +## Pré-requisitos + +FASES 0–7 concluídas. Ler `docs/06-LGPD-E-SEGURANCA.md` §3 e §4, e `docs/05-INFRA-DOCKER.md` §6. + +## Tarefas + +### 1. Canais de notificação plugáveis + +```kotlin +interface NotificationChannel { + val key: FeatureKey? // null = sempre ativo + suspend fun notify(alvo: NotificationTarget, evento: NotificationEvent): Result +} +``` + +`PushChannel` (FCM + APNs) sempre ativo. `WebSocketChannel` sempre ativo. + +`NotificationDispatcher` resolve os canais habilitados para o tenant e despacha **em paralelo**, tolerando falha individual — um canal fora do ar não pode impedir os demais. + +### 2. WhatsApp — interface e flag, sem integração + +O WhatsApp é **módulo pago opcional**; o app é o notificador principal. Nesta fase entrega-se apenas: + +- `WhatsAppChannel` com `key = FeatureKey.WHATSAPP_NOTIFICATIONS`, implementação **stub** que registra o que seria enviado +- Toggle no painel admin, com o efeito descrito em uma frase +- Estrutura de templates prevista +- Documentação em `docs/anexos/whatsapp-v2.md` com o que falta: WABA verificada, templates `utility` aprovados, custo (~US$0,004/msg no Brasil, grátis dentro da janela de 24h) + +**Não integre com a Meta agora.** Verificação de negócio e aprovação de template levam dias e bloqueariam o lançamento. E registre no documento a limitação de fundo: a latência do WhatsApp não serve para *tocar* uma chamada — ele é aviso paralelo com deep link, nunca o canal primário. + +### 3. Feature flags + +```kotlin +@Service +class FeatureService { + fun habilitado(tenantId: UUID, key: FeatureKey): Boolean + fun dentroDaQuota(tenantId: UUID, key: FeatureKey, uso: Int): Boolean +} +``` + +Cache curto (60s) com invalidação ao alterar. Toda mudança de flag vai para `audit_log` com autor. + +Quotas relevantes: minutos de vídeo por mês, gravações armazenadas, operadores simultâneos. + +### 4. Gravação de chamada + +Módulo `video_recording`. LiveKit Egress grava para o MinIO ao entrar em `EM_CHAMADA`, se habilitado. + +**Aviso reforçado obrigatório:** ao gravar, o visitante vê aviso explícito antes do início, e a versão desse aviso é registrada. Gravar sem avisar é violação direta. + +Retenção de 90 dias. Cada gravação soma banda no SFU — ative com quota. + +### 5. Retenção e expurgo + +Job diário `MEDIA_PURGE`: + +1. Seleciona `media_assets` com `expires_at < now()` e `storage_key IS NOT NULL` +2. Apaga o objeto no MinIO +3. `UPDATE media_assets SET storage_key = NULL, purged_at = now()` +4. **Preserva a linha** + +O passo 4 é o ponto: fica provado que existiu uma foto e que ela foi eliminada no prazo — exatamente o que se demonstra numa fiscalização. + +Prazos configuráveis por tenant, com **teto** definido pela plataforma. Um condomínio querendo guardar foto por 5 anos configuraria um risco que a plataforma não aceita hospedar. + +Job diário de limpeza de `idempotency_keys` vencidas. + +### 6. Direitos do titular + +`POST /admin/lgpd/export` — busca por CPF ou telefone, devolve JSON com todas as visitas, mídias (URLs assinadas) e registros de aviso. + +`POST /admin/lgpd/erase` — apaga mídia, anonimiza `visitor_name` para `[REMOVIDO]`, limpa documento e telefone, **preserva a linha da visita** com data, unidade e resultado. Justificativa: obrigação de guarda de registro de acesso para segurança patrimonial. A linha vira estatística, não identificação. + +Ambas registram em `audit_log` e devolvem comprovante em PDF — o condomínio precisa provar que atendeu no prazo de 15 dias. + +### 7. Versionamento do aviso de privacidade + +Editor no admin. Alterar o texto **cria nova versão**; as visitas antigas continuam apontando para a versão que foi realmente exibida. Sem isso, não há como provar o que o visitante leu naquele dia. + +### 8. SLOs medidos + +Instrumentar as métricas de `01-ARQUITETURA.md` §5.7: + +``` +portaria.visit.ring_latency QR → push entregue no dispositivo +portaria.visit.answered_ratio atendidas pelo morador / total +portaria.delivery.resolution_latency QR → resolvida +portaria.delivery.abandoned_ratio canceladas ou expiradas +portaria.entry_flow.availability caminho crítico +``` + +`ring_latency` mede até a **entrega** do push, não até o envio. A diferença entre os dois é justamente onde a falha acontece. + +Dashboards e alertas conforme `05-INFRA-DOCKER.md` §6, incluindo os dois alertas de negócio (taxa de atendimento baixa e tokens mortos) que viram tarefa de customer success, não plantão. + +### 9. Runbooks + +`docs/anexos/runbook-incidente.md` — vazamento, indisponibilidade, perda de dados; com prazos ANPD. +`docs/anexos/runbook-operacao.md` — restore testado, rotação de segredo, invalidação de QR, adição de condomínio. +`docs/anexos/LIA-modelo.md` — modelo de Legitimate Interest Assessment para preenchimento por condomínio. + +## Critérios de aceite + +- [ ] Dispatcher despacha em paralelo e tolera falha de um canal +- [ ] `WhatsAppChannel` desligado não é invocado; ligado, registra o que enviaria +- [ ] Toggle de módulo tem efeito imediato e fica em `audit_log` +- [ ] Gravação só ocorre com módulo ativo e aviso reforçado exibido +- [ ] `MEDIA_PURGE` apaga o objeto e preserva a linha +- [ ] Exportação LGPD devolve todos os dados de um titular +- [ ] Eliminação anonimiza e preserva a linha da visita +- [ ] Alterar o aviso cria versão; visitas antigas mantêm a original +- [ ] Todos os SLOs visíveis no Grafana com dados reais +- [ ] Alertas disparam em condição simulada +- [ ] Restore de backup testado e cronometrado + +## Não faça nesta fase + +- Integração real com a Meta (v2) +- Billing automatizado (v2) +- Ativar multi-tenancy diff --git a/prompts/FASE-9-hardening.md b/prompts/FASE-9-hardening.md new file mode 100644 index 0000000..993be2e --- /dev/null +++ b/prompts/FASE-9-hardening.md @@ -0,0 +1,126 @@ +# FASE 9 — Hardening e produção + +## Objetivo + +Provar que o sistema aguenta o mundo real: testes ponta a ponta, carga no SFU, injeção de falhas, revisão de segurança e deploy documentado. + +## Pré-requisitos + +FASES 0–8 concluídas. Ler `docs/06-LGPD-E-SEGURANCA.md` §5 e `docs/07-REQUISITOS-DE-CAMPO.md`. + +## Tarefas + +### 1. Testes ponta a ponta + +Playwright para os webs, Maestro para os apps. Cenários obrigatórios: + +1. Visita completa: QR → aviso → permissões → foto → unidade → morador atende → autoriza → PIN +2. Entrega em um toque, resolvida pela notificação, em menos de 10s +3. Escalonamento completo: ninguém atende → outros moradores → fila → operador → autorizado +4. Recado: ninguém atende, sem módulo de operador → grava → morador resolve depois +5. Unidade inexistente: visita criada, ninguém notificado, expira — **resposta indistinguível de unidade válida** +6. Fora do geofence: bloqueado com mensagem acionável +7. Quiet hours: desvia sem tocar em ninguém +8. Dois moradores respondendo juntos: um vence, o outro vê quem resolveu +9. LiveKit fora: degrada para áudio, depois foto+texto +10. Backend reiniciado no meio de uma visita: escalonamento acontece mesmo assim + +O cenário 10 é o que valida o padrão de timeouts persistidos. O 5 valida a busca cega. + +### 2. Teste de carga + +**SFU:** 50 chamadas simultâneas de 2 participantes, medindo banda, CPU e tempo até o primeiro frame. Confronte com o dimensionamento de `05-INFRA-DOCKER.md` §4. + +**API:** 100 visitas/minuto sustentadas — muito acima do real (~0,3 req/s), de propósito. + +**Fila:** 10 mil jobs agendados para o mesmo instante, verificando que nenhum é perdido ou executado duas vezes. + +**Banco:** tabela de auditoria com 1 milhão de visitas; a tela do admin precisa continuar fluida. + +### 3. Injeção de falhas + +Derrube cada peça, uma por vez, com uma visita em andamento, e verifique o comportamento: + +| Peça derrubada | Comportamento esperado | +|---|---| +| LiveKit | Degrada para áudio → foto+texto → operador | +| Redis | WebSocket degrada para polling; fila continua (está no Postgres) | +| MinIO | Visita prossegue sem foto, com aviso; upload entra em retry | +| FCM | WebSocket assume se o app está aberto; falha vai para o dashboard | +| Uma réplica do backend | Outra assume; jobs pendentes executam | +| Postgres (breve) | Erro claro ao visitante e contingência física acionada | + +**Teste do relógio:** atrase o relógio de uma réplica em 30s e confirme que ShedLock não permite execução dupla. + +### 4. Revisão de segurança + +Percorra o modelo de ameaças de `06-LGPD-E-SEGURANCA.md` §5 e prove cada mitigação: + +- [ ] **A.1 Busca cega** — tempo de resposta constante medido estatisticamente, não no olho. Este é o teste mais importante da fase +- [ ] **A.2 Geofence** — rejeita fora do raio; GPS falsificado é registrado +- [ ] **A.3 DoS social** — rate limit e quiet hours funcionam; varredura gera alerta +- [ ] **A.4 Mídia** — nenhuma URL pública; assinada expira; acesso auditado +- [ ] **A.5 Auditoria** — `UPDATE`/`DELETE` em `audit_log` falham no banco +- [ ] **A.6 Tokens** — JWT de visitante não serve para outra visita nem outra portaria +- [ ] **A.7 QR** — incrementar `qr_version` invalida os códigos antigos + +Rode também: `pnpm audit`, `gradle dependencyCheck`, varredura de imagem Docker (Trivy), e headers verificados por observatório de segurança. + +**Grep obrigatório:** nenhuma biblioteca de detecção ou reconhecimento facial em `build.gradle.kts` ou `package.json`. A tentação aparece disfarçada de UX ("só para enquadrar o rosto") e tem consequência jurídica direta. + +### 5. Checklist de produção + +**Infraestrutura** +- [ ] Faixa UDP 50000–60000 aberta (esquecer isso conecta a chamada e nunca mostra vídeo) +- [ ] TLS válido e renovação automática testada +- [ ] Backup automático com **restore testado e cronometrado** +- [ ] Segredos fora do repositório e fora das imagens +- [ ] Healthchecks respondendo em todos os serviços +- [ ] Alertas chegando em canal monitorado de verdade + +**Aplicação** +- [ ] Perfil `prod`, sem stack trace exposto +- [ ] Rate limits ativos +- [ ] CORS restrito aos domínios reais +- [ ] Logs sem dado pessoal (nome, foto, coordenada) — **verificar linha a linha** +- [ ] Migrations aplicadas em ordem em banco limpo + +**Apps** +- [ ] Builds de release assinados +- [ ] Testados em dispositivo físico, Android e iOS +- [ ] Ficha de privacidade da App Store e do Google Play preenchida +- [ ] Justificativa de VoIP documentada para a App Review +- [ ] Crash reporting **sem PII** + +**Conformidade** — checklist completo de `06-LGPD-E-SEGURANCA.md` §9. + +**Campo** — checklist de ativação de `07-REQUISITOS-DE-CAMPO.md` §2 para o condomínio piloto. + +### 6. Deploy + +`docs/anexos/deploy.md` com provisionamento, primeiro deploy, atualização sem downtime (rolling com healthcheck), rollback, aplicação de migration com dado em produção, e o procedimento de crescer para HA. + +### 7. Piloto assistido + +Antes da ativação definitiva, **duas semanas com o módulo de operador ligado**, monitoramento diário e reunião semanal com o síndico. + +O que se mede: taxa real de atendimento, tempo médio de resolução, abandono de entregador, incidentes, e feedback qualitativo de moradores e entregadores. + +**É esse número — não a expectativa comercial — que define se o condomínio fica no plano Autônomo ou no Assistido.** + +## Critérios de aceite + +- [ ] Os 10 cenários E2E passam em CI +- [ ] Carga do SFU dentro do dimensionamento previsto +- [ ] Toda injeção de falha se comporta como na tabela +- [ ] Todas as mitigações do modelo de ameaças verificadas +- [ ] Busca cega com tempo constante comprovado estatisticamente +- [ ] Checklist de produção 100% marcado +- [ ] Restore de backup executado e cronometrado +- [ ] Runbooks testados por alguém que não os escreveu +- [ ] Piloto de duas semanas concluído com métricas coletadas + +## Não faça nesta fase + +- Novas funcionalidades — se aparecer necessidade, vira v2 +- Otimização sem métrica que a justifique