diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6302626 --- /dev/null +++ b/.env.example @@ -0,0 +1,136 @@ +# Configuração do SinalACS — modelo. +# +# NÃO edite este arquivo com valores reais: ele é versionado. Gere o seu `.env` +# com segredos aleatórios próprios da sua máquina: +# +# ./scripts/dev/bootstrap_env.sh +# +# O `.env` é lido automaticamente pelo `docker compose` para substituir as +# `${VARIÁVEIS}` do docker-compose.yml, e é ignorado pelo git (.gitignore). +# +# Todo segredo abaixo é declarado no compose como `${VAR:?...}`: se faltar, o +# `docker compose` falha dizendo qual variável está ausente, em vez de subir a +# stack com uma senha embutida. Era exatamente esse o problema anterior. +# +# ATENÇÃO ao trocar senhas de uma stack que já rodou: +# · POSTGRES_PASSWORD só vale na PRIMEIRA inicialização do volume. Para +# trocar, apague ./pg_data/ antes de subir. +# · As senhas MQTT são regravadas a cada boot pelo mosquitto-init, então +# trocá-las aqui basta. + +# --------------------------------------------------------------------------- +# Google Maps Android SDK · consumido por: apps/acs +# +# Crie uma chave restrita no Google Cloud Console, habilite Maps SDK for +# Android, restrinja por package name/SHA-1 e mantenha o valor apenas no `.env`. +# O script de execução injeta a chave por dart-define e no manifest Android. +# Nunca versione uma chave real neste arquivo. +# --------------------------------------------------------------------------- +GOOGLE_MAPS_API_KEY= + +# --------------------------------------------------------------------------- +# Postgres da stack local · consumido por: postgres, serverpod, database-seed +# --------------------------------------------------------------------------- +POSTGRES_USER=sinalacs_user +POSTGRES_DB=sinalacs_db +# openssl rand -hex 32 +POSTGRES_PASSWORD= + +# --------------------------------------------------------------------------- +# Postgres do harness de teste do Serverpod +# +# Sobe com `docker compose --profile test up -d postgres-test`, na porta 9090, +# como backend/sinalacs_server/config/test.yaml espera. O bootstrap grava este +# mesmo valor em backend/sinalacs_server/config/passwords.yaml — os dois PRECISAM +# coincidir: se divergirem, o Serverpod falha ao carregar a config e chama +# exit(1), que não dá flush no stdout, e a suíte inteira morre sem uma linha de +# log sequer. +# --------------------------------------------------------------------------- +# openssl rand -hex 32 +TEST_DATABASE_PASSWORD= + +# --------------------------------------------------------------------------- +# Broker MQTT · consumido por: mosquitto-init (cria os usuários), serverpod +# +# Dois usuários, com ACLs distintas em infra/docker/mosquitto/aclfile: +# backend → readwrite em sinalacs/v1/# +# acs-area-12 → read no tópico da microárea, write nos ACKs +# --------------------------------------------------------------------------- +# openssl rand -hex 32 +MQTT_BACKEND_PASSWORD= +# openssl rand -hex 32 +MQTT_ACS_PASSWORD= + +# SANs adicionais no certificado do broker. Necessário para validar em aparelho +# físico na LAN, já que o certificado padrão só cobre mosquitto, localhost, +# 127.0.0.1 e 10.0.2.2 (o host visto do emulador Android). +# MQTT_CERT_SAN_EXTRA=IP:192.168.0.10 +MQTT_CERT_SAN_EXTRA= + +# --------------------------------------------------------------------------- +# Segredos da aplicação · consumido por: serverpod +# --------------------------------------------------------------------------- +# Chave HMAC que assina os tokens de auth.developmentLogin. O token carrega o +# papel e a microárea, então quem conhece este valor pode forjar um acesso de +# ACS para qualquer território. Nunca reaproveite entre ambientes. +# openssl rand -hex 32 +JWT_SECRET= + +# --------------------------------------------------------------------------- +# Cadeia de hash de audit_logs · consumido por: serverpod +# +# Chave HMAC que encadeia cada linha de audit_logs à anterior (ver +# backend/sinalacs_server/lib/src/application/audit/). Detecta edição, remoção +# ou reordenação de linhas — inclusive por quem tem acesso de escrita direto ao +# Postgres, que é por isso que este segredo NÃO pode ser derivado do +# JWT_SECRET nem reaproveitado dele. +# +# ATENÇÃO: rotacionar este valor invalida a verificação de TUDO que já foi +# gravado na trilha até aqui — ao contrário do JWT_SECRET, que pode ser trocado +# livremente. Trocar exige aceitar que a cadeia anterior à troca não verifica +# mais contra o segredo novo. +# openssl rand -hex 32 +AUDIT_CHAIN_SECRET= + +# --------------------------------------------------------------------------- +# Comportamento · consumido por: serverpod +# --------------------------------------------------------------------------- +# development | staging | production. Fora de development, o servidor recusa +# subir com JWT_SECRET ausente, vazio ou igual ao fallback de desenvolvimento. +APP_ENV=development + +# Gate de auth.developmentLogin. Com false, a chamada falha como se a rota não +# existisse. É o único mecanismo de autenticação do protótipo — não é +# autenticação institucional. +ENABLE_DEV_LOGIN=true + +# --------------------------------------------------------------------------- +# Apps Flutter · NÃO são lidos do .env +# +# Os apps resolvem estes valores em tempo de COMPILAÇÃO, via --dart-define, com +# defaults em apps/*/lib/core/network/backend_config.dart. +# +# Para o ACS, use o script — ele lê MQTT_ACS_PASSWORD daqui, copia a CA do +# broker para os assets e preenche tudo: +# +# ./scripts/dev/run_acs.sh +# ./scripts/dev/run_acs.sh --build +# +# O que ele passa: +# +# --dart-define=SINALACS_HOST=http://10.0.2.2:8080/ +# --dart-define=SINALACS_MQTT_HOST=10.0.2.2 +# --dart-define=SINALACS_MQTT_USER=acs-area-12 +# --dart-define=SINALACS_MQTT_PASSWORD= +# +# O default de 10.0.2.2 é o host da máquina visto de dentro do emulador Android. +# +# SINALACS_MQTT_PASSWORD **não tem default**: o broker usa um segredo por +# máquina, então nenhum valor embutido acertaria — o app compilado sem ele +# avisa que falta a senha, em vez de dizer "sem conexão com a central". +# +# AVISO: SINALACS_MQTT_PASSWORD vira uma CONSTANTE NO BINÁRIO — String.fromEnvironment +# é resolvido na compilação, e o valor é extraível de qualquer APK. Em produção a +# credencial do broker não pode viajar no app. A correção é credencial por +# dispositivo / mTLS, registrada como lacuna conhecida. +# --------------------------------------------------------------------------- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a62fc1..5622077 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,21 @@ jobs: defaults: run: working-directory: backend + env: + # Senha do Postgres efêmero deste job. NÃO é um segredo: o banco existe só + # dentro do runner, some ao fim do job e nunca é alcançável de fora. + # + # O que importa é que este valor não seja compartilhado com mais nada. + # Antes era a MESMA senha do config/passwords.yaml local — que o + # .gitignore protege e que é idêntica nos blocos development, test, + # staging e production. O segredo protegido pelo .gitignore estava, na + # prática, publicado aqui. + # + # Declarado uma vez e referenciado nos dois lugares: o serviço abaixo e a + # geração do passwords.yaml. Eram dois literais mantidos iguais à mão, e + # divergir fazia a suíte morrer sem log nenhum. + CI_POSTGRES_PASSWORD: ci-ephemeral-not-a-secret + services: # Banco que o harness de teste do Serverpod espera, conforme # sinalacs_server/config/test.yaml. O harness aplica as migrações sozinho @@ -20,7 +35,7 @@ jobs: image: postgres:15-alpine env: POSTGRES_USER: postgres - POSTGRES_PASSWORD: ZGib82sxRnAaTto3iglbPkARA1qZUv0W + POSTGRES_PASSWORD: ${{ env.CI_POSTGRES_PASSWORD }} POSTGRES_DB: sinalacs_test ports: - 9090:5432 @@ -41,11 +56,12 @@ jobs: # Serverpod não carrega a senha do banco, falha ainda no carregamento de # config e chama exit(1); como o exit() do Dart não dá flush no stdout, a # mensagem de erro se perde e a falha fica totalmente silenciosa (exit 1, - # zero linhas de log). A senha tem que ser a mesma do serviço postgres - # acima: se as duas divergirem, o harness volta a falhar sem log nenhum. + # zero linhas de log). + # + # Localmente, quem gera este arquivo é scripts/dev/bootstrap_env.sh. - name: Cria config/passwords.yaml para o harness de teste run: | - printf "test:\n database: '%s'\n" 'ZGib82sxRnAaTto3iglbPkARA1qZUv0W' \ + printf "test:\n database: '%s'\n" "$CI_POSTGRES_PASSWORD" \ > sinalacs_server/config/passwords.yaml # Suíte completa: 16 testes herméticos em test/unit, que usam fakes das # interfaces AlertPublisher e AlertStore, mais 9 de integração que sobem o @@ -90,3 +106,50 @@ jobs: - run: flutter pub get - run: flutter analyze - run: flutter test + + admin-app: + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/admin + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.44.8' + - run: flutter pub get + - run: flutter analyze + - run: flutter test + + # Único job do repositório que executa o Gradle. `flutter analyze` e + # `flutter test` não enxergam namespace inconsistente, AGP/Kotlin + # incompatíveis, MainActivity em pacote errado nem merge de manifest + # quebrado — erros que só apareceriam quando alguém tentasse compilar. + # + # É o app admin e não o ACS porque `apps/acs/android/app/build.gradle.kts` + # tem a guarda que faz o build falhar sem SINALACS_MQTT_PASSWORD: cobri-lo + # aqui exigiria compilar um APK que a própria guarda declara inútil. + admin-android-build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/admin + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.44.8' + # Sem cache, cada execução rebaixa ~200MB de Gradle e AGP. + - uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-admin-${{ hashFiles('apps/admin/android/**/*.gradle.kts', 'apps/admin/android/gradle/wrapper/gradle-wrapper.properties') }} + restore-keys: gradle-admin- + - run: flutter pub get + - run: flutter build apk --debug diff --git a/.gitignore b/.gitignore index c821156..80db99f 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ coverage/ # Environment .env .env.* +# ...menos o modelo, que é documentação e precisa ser versionado. Sem esta +# exceção o padrão .env.* acima o engoliria. +!.env.example # Docker / local runtime pg_data/ @@ -25,3 +28,7 @@ infra/docker/mosquitto/runtime/ # Grafo de conhecimento (saída regenerável do graphify) graphify-out/ + +# Asset de desenvolvimento: CA do broker MQTT, copiada por +# scripts/dev/sync_dev_ca.sh a partir do runtime regerável do Mosquitto. +apps/acs/assets/certs/ diff --git a/AGENTS.md b/AGENTS.md index a54d5b0..4ffc33c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,74 +2,157 @@ ## Visão geral do projeto -Este repositório contém a definição do produto, arquitetura e protótipos do SinalACS, uma plataforma para priorização de atendimentos em Atenção Primária à Saúde. +O SinalACS é uma plataforma para priorização de atendimentos na Atenção Primária à Saúde, transformando sinais clínicos estruturados em uma fila de trabalho para o ACS (Agente Comunitário de Saúde), ordenada por risco e preparada para operar em conectividade instável. -A intenção principal do projeto é apoiar dois fluxos centrais: +O repositório já contém um protótipo funcional com: +- backend em Dart/Serverpod; +- apps Flutter para paciente e ACS; +- PostgreSQL como persistência central; +- broker MQTT para entrega de alertas em tempo real; +- fluxo de sincronização offline para visitas do ACS. + +Os dois fluxos centrais continuam sendo: - paciente: autenticação simples, alerta de urgência, triagem estruturada e acompanhamento de status; -- ACS: priorização dinâmica, territorialização, registro offline e acompanhamento de microárea. - -## Documentos fundamentais - -Antes de implementar qualquer funcionalidade, consulte primeiro estes arquivos: -- [README.md](README.md) – visão geral do produto e contexto de negócio; -- [spec/idea.md](spec/idea.md) – conceito e objetivo do produto; -- [spec/PRD_system.md](spec/PRD_system.md) – requisitos, JTBD, invariantes e métricas; -- [spec/stack.md](spec/stack.md) – arquitetura de stack, infraestrutura e decisões técnicas; -- [spec/ui_design.md](spec/ui_design.md) – linguagem visual e comportamento de UX; -- [spec/ui_acs](spec/ui_acs) – protótipos das telas do ACS; -- [spec/ui_paciente](spec/ui_paciente) – protótipos das telas do paciente; -- [CLAUDE.md](CLAUDE.md) – guia de arquitetura e comandos para agentes de IA, mais atualizado que este arquivo quanto ao estado de implementação; -- [PROGRESS.md](PROGRESS.md) – status real dos milestones já implementados; -- [backend/](backend) – backend Serverpod (workspace Dart com `sinalacs_server` e o cliente gerado `sinalacs_client`); -- [apps/acs](apps/acs) e [apps/patient](apps/patient) – apps Flutter implementados; -- [docs/](docs) – documentação visual das telas dos apps. - -## Regras de trabalho para agentes de IA +- ACS: priorização dinâmica, territorialização por microárea, registro offline e acompanhamento do território. + +## Leitura obrigatória antes de implementar + +Antes de mexer em produto, arquitetura ou comportamento, consulte primeiro: +- [README.md](README.md) — visão geral do produto e estado atual; +- [spec/PRD_system.md](spec/PRD_system.md) — requisitos, JTBD, invariantes e métricas; +- [spec/stack.md](spec/stack.md) — decisões de stack e infraestrutura; +- [spec/ui_design.md](spec/ui_design.md) — linguagem visual e UX; +- [spec/lgpd_design.md](spec/lgpd_design.md) — privacidade e LGPD; +- [spec/lgpd_data_audit.md](spec/lgpd_data_audit.md) — classificação de sensibilidade LGPD, campo a campo, de todas as tabelas persistidas; +- [spec/ux_accessibility_assessment.md](spec/ux_accessibility_assessment.md) — auditoria WCAG 2.2 AA (contraste, alvo de toque, semântica) dos apps ACS, paciente e admin; +- [spec/ux_ui_test_plan.md](spec/ux_ui_test_plan.md) — plano de testes de UX/UI derivado de `spec/ui_design.md`; +- [CLAUDE.md](CLAUDE.md) — guia técnico e comandos para IA; é a referência mais atualizada do repositório; +- [PROGRESS.md](PROGRESS.md) — status dos milestones e histórico de migração; +- [backend/](backend) — workspace Dart com `sinalacs_server` e `sinalacs_client`; +- [apps/acs](apps/acs) e [apps/patient](apps/patient) — aplicativos Flutter reais; +- [spec/ui_acs](spec/ui_acs) e [spec/ui_paciente](spec/ui_paciente) — protótipos visuais e fluxos do produto. + +Quando houver conflito entre convenções gerais e documentação do projeto, a documentação do projeto vence. + +## Invariantes de negócio e segurança + +Não violar estes pontos sob qualquer hipótese: +- a microárea do ACS restringe o acesso apenas ao território correspondente; +- a classificação de risco é determinística e não pode ser alterada por intervenção manual no fluxo de triagem; +- alertas vermelhos nunca podem ser descartados silenciosamente; +- dados de saúde devem seguir os padrões de privacidade e LGPD; +- o cliente ACS deve operar mesmo com rede instável; +- a sincronização local/central é uma área crítica de risco arquitetural. + +## Arquitetura atual do repositório + +### Backend +- O backend está em um workspace Dart em [backend/](backend), com os pacotes `sinalacs_server` e `sinalacs_client`. +- A stack atual é Serverpod, não um servidor hand-rolled em `dart:io`. +- O servidor usa PostgreSQL e MQTT; a autenticação de desenvolvimento é opcional e não substitui autenticação institucional real. +- A camada de domínio fica em `backend/sinalacs_server/lib/src/application/` e a infraestrutura em `.../infrastructure/`. +- Os modelos e endpoints são definidos com Serverpod; não se deve editar manualmente arquivos gerados em `lib/src/generated/` ou migrações sem regenerar via `serverpod generate` e `serverpod create-migration`. +- O fluxo principal inclui: `auth.developmentLogin`, `triage.evaluate`, `alerts.createRedAlert`, `alerts.acknowledge` e `visits.sync`. +- MQTT conecta em background após boot, com reconexão exponencial e sem bloquear a API. + +### Apps Flutter +- A app do paciente e a app do ACS vivem em [apps/patient](apps/patient) e [apps/acs](apps/acs). +- Ambos usam o cliente gerado `sinalacs_client` por dependência local e não devem depender diretamente de detalhes de implementação do servidor em widgets. +- O app ACS é o mais crítico em termos de offline-first e sincronização: guarda visitas locais, tenta sincronizar em fila e trata conflitos sem perder registros. +- O tema visual é dark mode, com foco em legibilidade e uso de cor restrito a sinal clínico (vermelho, amarelo, verde). +- O app do paciente não deve reintroduzir regras de risco no cliente; a classificação deve vir do backend via `triage.evaluate`. +- [apps/admin](apps/admin) (`sinalacs_admin`) é o backoffice administrativo — deixou de ser um esqueleto de pubspec e hoje é um app navegável real, com 4 telas somente leitura (Indicadores, Microáreas, Alertas, Auditoria) atrás de `AdminHomeShell`. Ainda não consome `sinalacs_client`: usa a interface `AdminDataSource`, hoje implementada só por `MockAdminDataSource`, seguindo o mesmo padrão de DI de `PatientBackend`/`AcsBackend`. O login é local e não chama `auth.developmentLogin` (o backend só aceita `role: 'patient'`/`role: 'acs'` hoje). Toda tela que exibe dado sensível registra o próprio acesso via `recordAccess()` antes de renderizar, por exigência de auditoria do PRD §4.2.2. É o único dos três apps com suporte a Flutter Web, e desde a adição da plataforma Android também roda em celular e tablet (`flutter run -d emulator-5554`). O layout segue desktop-first: os pontos de quebra ficam em `lib/app/admin_layout.dart` e o layout compacto é complemento, nunca substituição — os dez testes de widget originais continuam passando sem edição, o que é o que prova isso. + +### Infraestrutura local +- O ambiente de desenvolvimento usa Docker Compose com PostgreSQL, Mosquitto, backend e Traefik. +- Há geração local de segredos via [scripts/dev/bootstrap_env.sh](scripts/dev/bootstrap_env.sh); o projeto não possui `.env` versionado. +- O servidor e o broker exigem valores configurados por ambiente; não reaproveitar credenciais do ambiente local em produção. + +## Regras de desenvolvimento ### 1) Preserve o contexto do produto -- A solução é orientada por risco clínico, não por roteiros geográficos fixos. +- A solução é orientada por risco clínico e não por roteiros geográficos fixos. - O foco do MVP é priorização e resposta rápida a urgências. -- O idioma principal do projeto e da documentação é o português. +- O idioma principal da documentação, comentários e textos de UI é o português. -### 2) Respeite a arquitetura definida -- O produto combina Flutter no cliente, um backend Serverpod em Dart e PostgreSQL como persistência central. O Serverpod era a decisão original de stack, ficou por um tempo não implementado — o servidor era um `dart:io` roteado à mão — e foi adotado depois, substituindo-o. -- O modelo offline-first é crítico; o ACS deve operar mesmo com rede instável. -- O MQTT é usado para entrega de alertas de urgência em tempo real. -- A sincronização local/central deve ser tratada como risco arquitetural principal. +### 2) Respeite a arquitetura escolhida +- Prefira soluções simples e previsíveis alinhadas ao stack já decidido: Flutter + Dart + PostgreSQL + MQTT. +- Não introduzir frameworks ou serviços novos sem justificativa no PRD, stack ou arquitetura do projeto. +- Ao alterar sincronização ou fila offline, preservar retry, deduplicação, conflito e persistência local. ### 3) Preserve invariantes de negócio e segurança -- A microárea da pessoa ACS deve restringir o acesso somente ao seu território. -- A classificação de risco deve ser determinística e não alterável por intervenção humana na triagem. -- Alertas vermelhos não podem ser descartados silenciosamente. -- Dados sensíveis de saúde devem seguir o padrão de privacidade e LGPD. +- Microárea deve restringir dados ao território do ACS. +- Red alert não pode ser perdido em silêncio. +- Triagem precisa ser determinística e consistente com o modelo do Protocolo de Manchester. +- Nenhum dado sensível de paciente deve entrar em logs, screenshots, testes, seeds ou configurações compartilhadas. ### 4) Ao criar ou alterar código -- Prefira soluções simples, previsíveis e alinhadas com a arquitetura já definida. -- Evite inventar recursos que não estejam no PRD ou no stack do projeto. -- Quando houver conflito entre convenções gerais e documentação do projeto, a documentação do projeto vence. -- Mantenha a lógica de priorização e triagem consistente com o modelo do Protocolo de Manchester e regras determinísticas. +- Manter a lógica de domínio separada da infraestrutura. +- Preferir interfaces e casos de uso testáveis, como no padrão de `application/` + `infrastructure/`. +- Evitar acoplamento de widgets e UI com o cliente gerado do backend. +- Quando houver mudança em modelos ou endpoints, gerar a migração correspondente em vez de editar arquivos gerados manualmente. ### 5) Quando o trabalho for de UI -- Siga a linguagem visual do dark mode, alta legibilidade e baixo ruído visual. -- Use a lógica de cores somente para sinalizar gravidade: vermelho, amarelo e verde são sinais clínicos, não elementos decorativos. -- Mantenha foco em mobile-first e acessibilidade. +- Manter dark mode, alta legibilidade e baixo ruído visual. +- Usar cores apenas para sinal clínico; não decorar interfaces com vermelho/amarelo/verde sem relação com risco. +- Manter foco em mobile-first e acessibilidade. +- Ao reaproveitar uma cor clínica de preenchimento (`red`/`accent`/`danger`) como cor de texto/ícone, usar a variante `*OnSurface` (`acs_theme.dart`/`patient_theme.dart`/`admin_theme.dart`) e medir contraste contra a superfície real (`Card`/`surfaceRaised`), não contra o fundo do Scaffold — ver `spec/ux_accessibility_assessment.md` e os testes em `test/contrast_tokens_test.dart` de cada app. ### 6) Quando o trabalho for de backend ou dados -- Considere cache local em SQLite/sqflite para uso offline. -- Planeje sincronização com retry, fila e resolução de conflitos. -- Preserve auditabilidade e rastreabilidade dos eventos de triagem e sincronização. +- Considerar uso de SQLite/SQLCipher e filas locais para operação offline. +- Planejar retry, reconciliação de conflitos e rastreabilidade de eventos. +- Manter a lógica de sincronização e persistência consistentes com o fluxo de visitas do ACS. + +## Comandos e validações importantes + +Antes do primeiro ambiente local: +```bash +./scripts/dev/bootstrap_env.sh +``` + +Subir a stack local: +```bash +docker compose up --build +``` + +Rodar testes do backend: +```bash +cd backend +dart pub get +cd sinalacs_server +dart test +``` + +Rodar análise do Flutter nos apps: +```bash +cd apps/patient && flutter pub get && flutter analyze && flutter test +cd apps/acs && flutter pub get && flutter analyze && flutter test +cd apps/admin && flutter pub get && flutter analyze && flutter test +cd apps/admin && flutter run -d emulator-5554 # no emulador Android +cd apps/admin && flutter test integration_test -d emulator-5554 # hermético: não precisa da stack +``` + +Importante: +- `flutter test` é hermético e não substitui validações com stack local real; +- os testes de integração e validação de conexão vivem fora do `flutter test` e utilizam a stack Docker/VM; +- o CI do projeto valida seis jobs separados: `serverpod-backend`, `backend-docker-build`, `patient-app`, `acs-app`, `admin-app` e `admin-android-build` (único que executa Gradle, compilando o APK do admin). + +## Observações finais + +Este repositório já não é apenas um conjunto de especificações. Ele contém um protótipo funcional validado localmente em stack Docker, com backend, apps reais e infraestrutura mínima operável para desenvolvimento. + +O estado atual não é produção: não há autenticação institucional real, não há mTLS no broker, e não existe deploy de produção concluído. Mesmo assim, qualquer mudança deve preservar a direção arquitetural do projeto e os invariantes de negócio definidos no PRD. -## Estrutura relevante do repositório +A documentação do produto, a arquitetura e os comandos de execução já estão no repositório; a implementação deve seguir o que está ali registrado e não inventar novos padrões sem alinhamento técnico e funcional. -- [spec/](spec) — artefatos de produto, arquitetura, requisitos e protótipos; -- [spec/ui_acs](spec/ui_acs) — fluxos do ACS; -- [spec/ui_paciente](spec/ui_paciente) — fluxos do paciente; -- [backend/](backend) — backend Serverpod; -- [apps/acs](apps/acs), [apps/patient](apps/patient) — apps Flutter reais; -- [docs/](docs) — documentação visual das telas. +## Graphify -## Observações para agentes +Este projeto possui um grafo de conhecimento em [graphify-out](graphify-out), com nós e relações entre arquivos e conceitos. -Este repositório já tem um protótipo funcional implementado (backend Serverpod, apps Flutter de paciente e ACS, CI validando os três), validado localmente via Docker Compose — não é mais só especificação/prototipação. Qualquer código adicionado deve refletir as decisões capturadas em [spec/stack.md](spec/stack.md), [spec/PRD_system.md](spec/PRD_system.md) e [spec/ui_design.md](spec/ui_design.md) quando ainda válidas. Atenção ao ler o [PROGRESS.md](PROGRESS.md): suas entradas M1.x e M2.x descrevem o servidor `dart:io` que existiu antes da migração para Serverpod, e seus links apontam para código que só existe no histórico do git — a seção "Migração para Serverpod", ao final daquele documento, registra o estado atual. O [CLAUDE.md](CLAUDE.md) é a referência de arquitetura e comandos. +Quando estiver explorando o código e precisar entender ligações entre módulos, prefira: +- `graphify query ""` quando o grafo existir; +- `graphify path "" ""` para traçar relações; +- `graphify explain ""` para foco em um tema específico; +- `graphify update .` após alterações de código para manter o grafo atualizado. -Se a tarefa solicitar implementação, priorize a consistência com os documentos acima e mantenha o comportamento alinhado ao MVP definido no PRD. +Se o diretório [graphify-out/wiki](graphify-out/wiki) existir, use-o para navegação ampla antes de navegar por arquivos isolados. diff --git a/CLAUDE.md b/CLAUDE.md index 79227ef..7248c60 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,9 @@ Read these before making product/architecture decisions — when project docs co - [spec/stack.md](spec/stack.md) — stack/infra architecture decisions. - [spec/ui_design.md](spec/ui_design.md) — visual language and UX behavior. - [spec/lgpd_design.md](spec/lgpd_design.md) — privacy/LGPD design. +- [spec/lgpd_data_audit.md](spec/lgpd_data_audit.md) — field-by-field LGPD sensitivity classification for every persisted table (11 domain tables plus Serverpod's own). +- [spec/ux_accessibility_assessment.md](spec/ux_accessibility_assessment.md) — WCAG 2.2 AA audit (contrast, touch targets, semantics) for the ACS/patient/admin apps; read before touching any color used as text/icon, not just fill. +- [spec/ux_ui_test_plan.md](spec/ux_ui_test_plan.md) — UX/UI test plan derived from `spec/ui_design.md` (visual/interaction behavior, complementary to the accessibility assessment). - [AGENTS.md](AGENTS.md) — full agent working rules (Portuguese), summarized below. ## Business/security invariants (do not violate) @@ -28,6 +31,31 @@ Read these before making product/architecture decisions — when project docs co ## Commands +### Configuration — run this first + +```bash +./scripts/dev/bootstrap_env.sh # cria .env e config/passwords.yaml +``` + +There is no committed `.env`, and the stack will not start without one: every +secret in `docker-compose.yml` is declared as `${VAR:?...}`, so a missing value +fails the Compose interpolation by name instead of falling back to a password +baked into the versioned file. [.env.example](.env.example) is the full +reference for every variable. + +The script generates random per-machine values for `POSTGRES_PASSWORD`, +`TEST_DATABASE_PASSWORD`, `MQTT_BACKEND_PASSWORD`, `MQTT_ACS_PASSWORD`, +`JWT_SECRET` and `AUDIT_CHAIN_SECRET`, writes `backend/sinalacs_server/config/passwords.yaml` with the +same test-database password, and `chmod 600` on both. It never overwrites +existing files without `--force`, and warns when `pg_data/` predates a rotation +(`POSTGRES_PASSWORD` only takes effect on the volume's first init — to adopt a +new one, `docker compose down && rm -rf pg_data/`). + +Rotating the MQTT passwords is enough on its own: `infra/docker/mosquitto/init.sh` +rewrites the broker's `passwordfile` on every boot. It used to only create it +when absent, so a changed password silently did nothing and the broker kept +rejecting the new one. + ### Local stack (Postgres + Mosquitto + backend + Traefik) ```bash docker compose up --build @@ -42,13 +70,23 @@ cd backend dart pub get dart analyze cd sinalacs_server -dart test # 25 tests: 16 unit + 9 integration +dart test # 83 tests: 69 unit + 14 integration dart test test/unit # hermetic only, no database needed dart test test/unit/red_alert_service_test.dart # single test file ``` Integration tests use Serverpod's `withServerpod` harness, which needs the test database from `sinalacs_server/config/test.yaml` — Postgres on `localhost:9090`, database `sinalacs_test`, user `postgres`, password from the `test:` block of `config/passwords.yaml` (gitignored). The harness applies migrations itself and rolls the database back after each case, so there is **no** manual schema or seed step. -Because `config/passwords.yaml` is gitignored, it is missing from any fresh checkout — including CI, where the workflow generates it before running the suite. Without it, Serverpod fails while loading config and calls `exit(1)`; since Dart's `exit()` does not flush stdout, the error message is lost and the whole suite dies with exit code 1 and **zero** output. If you ever see that signature, check this file first. +Start that database with the `test` profile of the root compose: + +```bash +docker compose --profile test up -d postgres-test +``` + +It replaced `backend/sinalacs_server/docker-compose.yaml`, the Serverpod template +compose that carried four passwords in cleartext in git and was referenced by +nothing. + +Because `config/passwords.yaml` is gitignored, it is missing from any fresh checkout. Locally `scripts/dev/bootstrap_env.sh` generates it; CI generates it in the job. Without it, Serverpod fails while loading config and calls `exit(1)`; since Dart's `exit()` does not flush stdout, the error message is lost and the whole suite dies with exit code 1 and **zero** output. If you ever see that signature, check this file first. The password in it **must** match `TEST_DATABASE_PASSWORD` in `.env` — the bootstrap script keeps them aligned and warns when they drift. After changing any `.spy.yaml` model or adding an endpoint, regenerate and create a migration (from `backend/sinalacs_server`): ```bash @@ -59,7 +97,9 @@ serverpod create-migration Run the server directly: `dart run backend/sinalacs_server/bin/main.dart`. Serverpod reads `config/*.yaml` plus `config/passwords.yaml`, and every setting can be overridden by environment variable: `SERVERPOD_DATABASE_HOST`/`_PORT`/`_NAME`/`_USER`/`_PASSWORD`/`_REQUIRE_SSL`, `SERVERPOD_APPLY_MIGRATIONS` (applies migrations at boot — this is what replaced the manual `psql` steps), `SERVERPOD_REDIS_ENABLED` (Redis is optional and off), and `SERVERPOD_INSIGHTS_SERVER_PORT` (remapped to 8083 in `docker-compose.yml`, because Serverpod's default 8081 is the Traefik dashboard here). -MQTT is not part of Serverpod and keeps its own env vars, read by `AppConfig.fromEnvironment()` in `backend/sinalacs_server/lib/src/config/app_config.dart`: `MQTT_BROKER`/`MQTT_USERNAME`/`MQTT_PASSWORD`/`MQTT_USE_TLS`/`MQTT_CA_CERT_PATH`, plus `JWT_SECRET`, `APP_ENV` (when `production`, boot fails fast if `JWT_SECRET` is missing) and `ENABLE_DEV_LOGIN` (default `false` — gates `auth.developmentLogin`, which fails as if the route did not exist when off). +MQTT is not part of Serverpod and keeps its own env vars, read by `AppConfig.fromEnvironment()` in `backend/sinalacs_server/lib/src/config/app_config.dart`: `MQTT_BROKER`/`MQTT_USERNAME`/`MQTT_PASSWORD`/`MQTT_USE_TLS`/`MQTT_CA_CERT_PATH`, plus `JWT_SECRET`, `AUDIT_CHAIN_SECRET`, `APP_ENV` and `ENABLE_DEV_LOGIN` (default `false` — gates `auth.developmentLogin`, which fails as if the route did not exist when off). + +Outside `development`, boot fails fast when `JWT_SECRET` or `AUDIT_CHAIN_SECRET` is absent, empty, blank, or equal to its own development fallback (`AppConfig.developmentJwtSecret` / `AppConfig.developmentAuditChainSecret`) — those fallbacks live in versioned code, so they're public, and `JWT_SECRET` signs tokens that carry role and micro-area while `AUDIT_CHAIN_SECRET` keys the `audit_logs` hash chain. The two are deliberately independent secrets — rotating one must not affect the other. The shared validation logic is `_resolveSecret`, covered by `test/unit/app_config_test.dart`; `AppConfig.fromMap()` exists so the rules are testable without touching `Platform.environment`. The dev seed (`sinalacs_server/lib/src/infrastructure/database/seeds/development.sql`) is not optional for a running stack: `auth.developmentLogin` issues tokens for fixed UUIDs, and `alerts.patientId` has a foreign key to `patients` — without the seed, `alerts.createRedAlert` fails with a foreign-key violation. In `docker-compose.yml` the `database-seed` service applies it after the server is healthy. @@ -70,12 +110,30 @@ flutter pub get flutter analyze flutter test flutter test test/login_flow_test.dart # single test file -flutter run +flutter run # patient only flutter build apk --debug # debug APK, validated with compileSdk/targetSdk 36 ``` -`apps/admin` exists only as a pubspec skeleton (backoffice), no implementation yet. +For the ACS app, run `./scripts/dev/run_acs.sh` (or `--build` for the APK) instead of bare `flutter run`: `SINALACS_MQTT_PASSWORD` is a compile-time constant with **no default**, and the broker's password is generated per machine by `bootstrap_env.sh`. The script reads `.env`, runs `sync_dev_ca.sh` (the CA is a gitignored asset the build requires) and passes all four defines via `--dart-define-from-file` (a temp file it creates and deletes, so the password never sits in `flutter`'s argv). `apps/acs/android/app/build.gradle.kts` makes a bare `flutter build apk` **fail** with the right command instead of silently producing an APK that never connects; the escape hatch for a deliberately-passwordless build (e.g. to see the "compiled without the password" banner) is `-Psinalacs.allowMissingMqttPassword=true`. + +`apps/admin` (package `sinalacs_admin`) is a real backoffice app now, not a skeleton — see Architecture below. Same commands apply (`cd apps/admin && flutter pub get && flutter analyze && flutter test`); it's the only one of the three with Flutter Web enabled (`flutter build web` works), and since the Android platform was added it also runs on a device: `flutter run -d emulator-5554`, `flutter build apk --debug`, and `flutter test integration_test -d emulator-5554`. Unlike the ACS app there is no wrapper script and no `--dart-define` to remember — the admin has no secrets. + +### Validating the real connection to the backend + +`flutter test` stays hermetic (it only runs `test/`, where the backend is a fake). Anything that needs the live stack lives outside it: + +```bash +./scripts/qa/e2e.sh # sobe a stack, valida na VM, derruba +./scripts/qa/e2e.sh --keep # mantém a stack de pé +./scripts/qa/e2e.sh --emulator # inclui integration_test em um emulador já aberto +``` + +The script brings up Docker Compose, waits for the healthcheck, applies the seed, runs `scripts/dev/sync_dev_ca.sh` (copies the broker CA into `apps/acs/assets/certs/`, which is gitignored and regenerated), and then runs each app's `tool/live_check.dart`. Those scripts run on the plain Dart VM — no emulator — using the apps' own network code: the patient one covers health/login/triage/alert/idempotency, and the ACS one covers the full cycle including the MQTT/TLS subscription and `visits.sync`. -CI (`.github/workflows/ci.yml`) runs four parallel jobs on push/PR to main: `serverpod-backend` (spins up the Postgres the test harness expects on port 9090, then `dart analyze` and the full 25-test suite), `backend-docker-build` (builds `backend/sinalacs_server/Dockerfile` to catch build breakage before deploy), `patient-app`, `acs-app` (each `flutter analyze && flutter test`, Flutter 3.44.8). Mirror this locally before pushing. +`integration_test/` in each app holds the on-device version, excluded from `flutter test` by construction. Run it against an already-running emulator with `--dart-define=SINALACS_HOST=http://10.0.2.2:8080/` (and, for the ACS, `SINALACS_MQTT_HOST=10.0.2.2` plus `SINALACS_MQTT_PASSWORD="$MQTT_ACS_PASSWORD"` — without the password the suite fails in `setUpAll` with the command you need). `scripts/qa/e2e.sh --emulator` already passes all three. CI's four jobs are unchanged and never need a backend. + +Debug builds of all three apps carry `android/app/src/debug/res/xml/network_security_config.xml`, which permits cleartext only to `10.0.2.2` and loopback; release manifests are untouched. + +CI (`.github/workflows/ci.yml`) runs six parallel jobs on push/PR to main: `serverpod-backend` (spins up the Postgres the test harness expects on port 9090, then `dart analyze` and the full 25-test suite), `backend-docker-build` (builds `backend/sinalacs_server/Dockerfile` to catch build breakage before deploy), `patient-app`, `acs-app`, `admin-app` (each `flutter analyze && flutter test`, Flutter 3.44.8), and `admin-android-build` (`flutter build apk --debug` with a Gradle cache). That last one is the only job in the repository that executes Gradle at all: `analyze` and `test` are blind to an inconsistent `namespace`, incompatible AGP/Kotlin, a `MainActivity` in the wrong package or a broken manifest merge. It covers the admin rather than the ACS because `apps/acs/android/app/build.gradle.kts` deliberately fails the build without `SINALACS_MQTT_PASSWORD`. Mirror this locally before pushing. ## Architecture @@ -93,17 +151,28 @@ Never hand-edit anything under `lib/src/generated/` or `migrations/` — run `se Key pattern: application services depend on abstract interfaces (`AlertPublisher`, `AlertStore`) defined alongside them in `application/`, implemented by `infrastructure/`. Follow this when adding new use cases — keep `application/` testable without real Postgres/MQTT (see how `test/unit/red_alert_service_test.dart` fakes both). -**Serverpod is RPC, not REST**, so there are no URL routes to match: the generated client calls methods. Endpoints: `health.check` (returns `{status, mqttConnected, dbConnected}`; answers as soon as the server is up, independent of MQTT/DB state), `auth.developmentLogin` (throws `EndpointDisabledException` unless `ENABLE_DEV_LOGIN=true`, preserving the old 404-not-403 semantics), `alerts.createRedAlert` (idempotency key is a method parameter, not a header; throws `AlertDispatchUnavailableException` if the MQTT dispatcher isn't connected), `alerts.acknowledge`, and `triage.evaluate`. Errors are typed exceptions declared in `.spy.yaml` and serialized to the client, replacing HTTP status codes. MQTT connects in the background after boot (non-blocking) with exponential-backoff auto-reconnect, so the server stays responsive even if the broker is unreachable — this matters on free-tier hosts that sleep/hibernate. +**Serverpod is RPC, not REST**, so there are no URL routes to match: the generated client calls methods. Endpoints: `health.check` (returns `{status, mqttConnected, dbConnected}`; answers as soon as the server is up, independent of MQTT/DB state), `auth.developmentLogin` (throws `EndpointDisabledException` unless `ENABLE_DEV_LOGIN=true`, preserving the old 404-not-403 semantics), `alerts.createRedAlert` (idempotency key is a method parameter, not a header; throws `AlertDispatchUnavailableException` if the MQTT dispatcher isn't connected), `alerts.acknowledge`, `triage.evaluate`, `visits.sync` (batch upload of visits registered offline by the ACS; deduplicated by the device-generated `localId`, which has a unique index on `visits`, version-checked — a mismatched `version` returns `SyncStatus.conflict` and never overwrites — and territory-checked against the patient's own micro-area, not just the caller's; a malformed identifier, a territory mismatch, or a visit owned by another ACS all return the terminal `SyncStatus.rejected`, distinct from the retryable `SyncStatus.error` used for things like an unknown patient, so the device queue knows which failures are worth retrying), and `patients.listMicroArea` (the ACS's routine-visit patient picker; the micro-area comes from the caller's token, never a parameter, and every call is written to `audit_logs`, whose rows are hash-chained — `AuditChain`/`AuditChainVerifier` in `application/audit/`, keyed by `AUDIT_CHAIN_SECRET` — so tampering with a row is detectable even by someone with direct Postgres write access; `bin/audit_chain_check.dart` verifies the chain on demand). Errors are typed exceptions declared in `.spy.yaml` and serialized to the client, replacing HTTP status codes. MQTT connects in the background after boot (non-blocking) with exponential-backoff auto-reconnect, so the server stays responsive even if the broker is unreachable — this matters on free-tier hosts that sleep/hibernate. ### Flutter apps (`apps/acs/`, `apps/patient/`) Both follow the same skeleton: `lib/main.dart` → `lib/app/app.dart` (+ `*_theme.dart` for the dark, high-legibility, low-noise visual language — see `spec/ui_design.md`) → `lib/core/`. Shared `core/` concerns: -- `database/encrypted_database.dart` — local persistence via `sqflite_sqlcipher`, with an FFI fallback for test/VM environments where SQLCipher isn't available. -- ACS-only, in `apps/acs/lib/core/services/`: `mqtt_secure_client.dart` (TLS/WSS MQTT client, per-micro-area topics, alert + ACK payload parsing with malformed-message rejection), `offline_visit_queue.dart` (offline-first visit queue: pending → sync batch → retry/conflict detection, drives the same state shape as the backend's `SyncFsm`), `network_chaos_simulator.dart` (injects latency/jitter/packet loss/partition for testing offline resilience — see `apps/acs/test/network_chaos_test.dart`). +- `database/encrypted_database.dart` (ACS only) — local persistence via `sqflite_sqlcipher`. **INV-04 of the PRD says health data is never persisted in plaintext**, so opening outside Android/iOS throws unless the caller passes `allowUnencryptedForTesting: true`. That flag is deliberately embarrassing to type: `grep` for it and you see every place that gave up the guarantee — today, only VM tests. It used to be the *default* behaviour off-mobile, which is why the old test named "deve abrir banco criptografado" proved nothing on Linux CI. +- `security/database_key_store.dart` (ACS only) — the 256-bit key lives in the Android Keystore / iOS Keychain (`flutter_secure_storage`), never in code. **Not** derived from a PIN, though `spec/PRD_system.md` 4.2.3 prescribes PBKDF2-from-PIN: no PIN flow exists in the apps, and the decision plus its reversibility is recorded in `spec/lgpd_design.md` §5.1. `SqlCipherVisitStore` recovers from a lost key (reinstall, backup restore) by discarding the unreadable file instead of leaving the app permanently stuck. +- **Encryption is only actually proven on a device**: `apps/acs/integration_test/encrypted_storage_test.dart` reads the raw database file and asserts it neither starts with `SQLite format 3` nor contains the visit content. `flutter test` cannot prove this — on Linux everything goes through the unencrypted FFI path, which is the trap that produced the original gap. +- ACS-only, in `apps/acs/lib/core/services/`: `mqtt_secure_client.dart` (TCP/TLS MQTT client on 8883 — the broker's only published listener; trusts the dev CA from an asset via `setTrustedCertificatesBytes`, keeps hostname verification on, uses a persistent session with a stable client id so the broker re-delivers alerts that arrived while the device was offline), `alert_feed.dart` (builds the MQTT config from the session and feeds `alert_queue.dart`; classifies failures into `AlertFeedFailure.transient` — a missing password/CA asset or a refused credential is not, an unreachable broker is), `reconnect_schedule.dart` (pure 2s→60s exponential backoff, same scale as the server's `MqttAlertDispatcher`, with no `Timer` of its own so it is testable without a fake clock — used by `AcsHomeShell` in `app.dart` to retry the *first* connection to the broker on its own when the failure is transient, with a "Tentar agora" button on the banner and an immediate retry on returning from the background; a permanent failure, such as a missing password, is not retried. `mqtt_client`'s own `autoReconnect` only takes over *after* a first successful connection — before that there is nothing to reconnect, which is why this existed as a real gap: a device that opened the app without signal used to need a restart to ever receive the alerts the broker was already holding for it with QoS 1), `offline_visit_queue.dart` (offline-first visit queue backed by a `VisitStore` — in production `SqlCipherVisitStore`, so visits survive closing the app; a store failure sets `persistenceFailed` and the queue keeps working in RAM rather than blocking field work, surfaced as its **own** banner (`Key('storage_error')`) alongside — never instead of — the broker one, plus an inline warning on the visit screen; the two used to share one slot through a `??`, so a field outage hid the fact that visits were not being saved — pushed to `visits.sync` through `backend_visit_synchronizer.dart`, wired in production by `visit_queue_factory.dart`; a conflict returns to the queue instead of being discarded, a per-visit retryable `error` also stays pending and is reported with the server's message, a network failure keeps the batch pending, and a per-visit terminal `rejected` leaves the retry queue but stays visible on the device (`rejectedCount`/`rejectedVisits`, `Key('rejected_visits_count')`) with the server's reason until the ACS explicitly discards it (`discardRejected()`, `Key('discard_rejected')`, behind a confirmation dialog) — nothing removes a rejected visit from disk on its own). An `OfflineVisitRecord` carries the patient's **UUID**, never a display label: when the visit comes from an alert the screen builds `Paciente <8 hex>` at render time, `visits.sync` rejects anything that is not a UUID, and the local schema (`offline_visits`, v4) has `patient_id` plus a nullable `rejection_reason`. The backend only ever publishes `riskLevel: 'red'` (emergency, routed to escalation), so a routine visit — the PRD's actual usage pattern, ≥8/ACS/day — cannot start from an alert at all; `patients.listMicroArea` (backend, territorialized by the caller's token, never a client-supplied micro-area) and the picker it feeds in `VisitRegistrationScreen` (`Key('patient_picker')`) exist for that path, showing name and chronic conditions per `spec/lgpd_design.md`'s routine-visit minimization rule — the picked name lives only in memory for the label, never written to `offline_visits` or logged. The red-alert path keeps its own way in too: the escalation screen's "Iniciar rota de visita" (`Key('escalation_visit')`) — the ACS visits after triggering SAMU, not instead of it. `VisitSyncService` also checks that the patient's micro-area matches the ACS's, not just that the ACS is territorialized; a mismatch is the terminal `SyncStatus.rejected` per-visit (never discards the rest of the batch) and writes an `audit_logs` row (`result: denied_territory`) per `spec/lgpd_design.md`'s access-monitoring requirement — the first writer that table ever had. `EncryptedLocalDatabase._upgrade` drops and recreates the table on v1 → v2 — acceptable only because the app had not shipped a release yet; from v3 → v4 onward the migration is additive (`ALTER TABLE ... ADD COLUMN`) and preserves existing rows, which is what any migration after a real release must do. Sync is triggered by hand from the visit screen ("Sincronizar agora" plus pending/conflict counters), not automatically, `network_chaos_simulator.dart` (injects latency/jitter/packet loss/partition for testing offline resilience — see `apps/acs/test/network_chaos_test.dart`). + +Topic namespace must agree in three places: the server's `AlertDelivery.topicPrefix`, the broker ACL (`infra/docker/mosquitto/aclfile`), and `alertTopicFor()` in `mqtt_secure_client.dart` — all `sinalacs/v1/microareas//alerts`, where `` is the seed UUID, not `area-12`. -Neither app is yet wired to the real backend HTTP/MQTT endpoints end-to-end (per `PROGRESS.md`) — triage/risk logic in the patient app and prioritization in the ACS app currently run client-side against local/mock data, mirroring the backend's `triage_engine.dart` logic but not yet calling it over the network. +Both apps consume the generated `sinalacs_client` by path dependency and talk to the real backend. The network layer lives in `lib/core/network/` in each app: `backend_config.dart` (host via `--dart-define`, defaulting to `http://10.0.2.2:8080/`, the machine as seen from the Android emulator), `backend_client.dart` (a `PatientBackend`/`AcsBackend` interface plus the real `BackendClient`, which translates the typed backend exceptions into `BackendFailure` messages in Portuguese), `auth_session.dart` (reads the dev token's payload — **without** verifying the signature, which is the server's job — only to learn the micro-area and the 15-minute expiry) and `backend_scope.dart`. The UI depends on the interface, never on the generated `Client`, which is what keeps the widget tests hermetic; the live path is checked by `tool/live_check.dart` in each app and by `integration_test/`. + +Risk classification now comes **only** from `triage.evaluate`: the patient app's client-side string-matching rule is gone, and its triage form asks the six symptoms the server's engine actually takes. The ACS dashboard is fed by `AlertQueue`, which receives alerts over MQTT and orders them deterministically by risk and then by age; it rejects alerts from another micro-area and de-duplicates re-deliveries (QoS 1 is at-least-once). Color is a clinical signal only in these apps: red/yellow/green map strictly to `RiskLevel`, never used decoratively. +**WCAG contrast tokens**: `app/acs_theme.dart`, `app/patient_theme.dart` and `app/admin_theme.dart` each carry a text-safe variant of every clinical fill color — `redOnSurface`/`accentOnSurface` (ACS), `dangerOnSurface`/`accentOnSurface` (patient), `redOnSurface`/`accentOnSurface` (admin). `red`/`accent`/`danger` only clear WCAG 1.4.3's 4.5:1 as a *fill* (e.g. white text on a red button); reused as *text* color on `surfaceRaised`/`Card` — where risk/status text is actually rendered — they drop to ~3:1. `acsOnSurface()`/`adminOnSurface()` (and the patient-app equivalent) convert fill → text color at the single point where a `switch (riskLevel)` used to feed both, instead of at every call site. The admin's `accentOnSurface` (`#818CF8`, indigo-400) is deliberately *not* the ACS's `#60A5FA`: the admin's fill is indigo `#4F46E5`, not the ACS's blue `#2563EB` — the shared rule across apps is "same color, -400 step," not a literal value, and copying the ACS constant would have passed contrast while clashing with the accent-colored border right next to it. `test/contrast_tokens_test.dart` in each app checks the full token×surface matrix by computing relative luminance directly (`test/support/contrast.dart`) rather than by eyeballing a contrast checker against the wrong surface — which is exactly the false positive/negative `spec/ux_accessibility_assessment.md` documents from the original manual audit. The same pass added `minimumSize` (48×52dp, 64×60dp for the SAMU emergency call) per WCAG 2.5.5 and `Semantics(liveRegion: true)` on dynamic status messages (login errors, feed/storage errors, rejected-visit counts) per WCAG 4.1.3. + +### Admin app (`apps/admin/`) +Package `sinalacs_admin`, read-only backoffice. Same skeleton as ACS/patient: `lib/main.dart` → `lib/app/app.dart` (+ `admin_theme.dart`) → `lib/core/data/`. Unlike ACS/patient it does not consume `sinalacs_client` yet: `AdminDataSource` (`lib/core/data/admin_data_source.dart`) is an abstract interface whose models (`RiskLevel`, `AlertStatus`, `DashboardIndicators`, `MicroAreaSummary`, `AlertSummary`, `AuditLogEntry`) mirror the backend's `.spy.yaml` models field-for-field, backed today only by `MockAdminDataSource` — swapping in a real implementation over `sinalacs_client` shouldn't require reshaping the screens, the same DI pattern as `PatientBackend`/`AcsBackend`. `AdminHomeShell` exposes four read-only sections: **Indicadores** (risk counters + TMRAV, the PRD's North Star metric), **Microáreas** (ACS↔microarea binding), **Alertas** (filterable by micro-area/status, with filter options sourced from the fetched data itself rather than a hardcoded list — a hardcoded list would silently drop a real micro-area as a selectable option) and **Auditoria** (an `audit_logs` viewer). Every screen touching sensitive data calls `dataSource.recordAccess(actionType: 'view', resourceType: ...)` *before* rendering it, per `spec/PRD_system.md` §4.2.2 (admin access must itself be audited) — the audit log screen audits its own access too, and a failed `recordAccess` blocks the data from rendering rather than failing open. Login is intentionally local-only, **not** wired to `auth.developmentLogin`: the backend's `auth_endpoint.dart` only accepts `role: 'patient'`/`role: 'acs'`, there is no fixed dev user for `admin`, so real auth is out of scope until the backend supports it (see the `LoginScreen` doc comment in `app.dart`). The "ambiente de desenvolvimento" banner is informational, not access control — the actual gate is `devLoginEnabled`, which defaults to `kDebugMode` and disables the login bypass outside debug builds. Layout is desktop-first per `spec/PRD_system.md` §2.1: `NavigationRail` above `AdminBreakpoints.rail`, `NavigationBar` below, same `ThemeData` either way. The breakpoints live in `lib/app/admin_layout.dart` (`rail` 640, `stacked` 480, `counterCardMin` 160) plus `adminHeaderHeight()`, which exists because `PreferredSizeWidget.preferredSize` is a getter with no `BuildContext` — without it the two-line header clips instead of growing under a large system font. Android was added after the web-only phase, and mobile is a complement, never a replacement: below `stacked` the two alert filters stack instead of sharing a row, `_LinhaComSelo` moves a `ListTile`'s `trailing` badge down into the subtitle (Microáreas and Auditoria had the same squeeze), `_InfoRow` stacks label over value, the header's "Acesso auditado" chip becomes an icon that keeps its `Semantics` label, and the counter cards use a computed grid instead of a fixed `width: 160`. `NavigationRail` is `scrollable: true` because a phone in landscape clears the 640 threshold with only ~288dp of height left — it fits by a few pixels at default font size and overflows at 150%. The guard against regressing any of this is that the ten original widget tests still pass **unedited** at 800×600; the new `test/responsive_layout_test.dart`, `text_scale_test.dart`, `alerts_filters_layout_test.dart`, `admin_header_test.dart` and `touch_targets_test.dart` drive `test/support/layout_harness.dart`, whose header documents the three ways an overflow test silently goes blind (checking before paint, never scrolling a `ListView` past the first fold, and `takeException()` consuming one exception per call) — `test/layout_harness_sanity_test.dart` proves the detector still detects. **`apps/admin/integration_test/` is hermetic**, unlike the ACS and patient ones described above: it runs on `MockAdminDataSource`, so `flutter test integration_test -d emulator-5554` needs no `docker compose`, no seed and no `--dart-define`. `apps/admin` is the only one of the three apps with Flutter Web enabled and the only one versioning `pubspec.lock` from the start (the other two adopted that convention later). `docs/telas-admin.md` documents its five screens with real screenshots, mirroring `docs/telas-acs.md`. CI covers it in the `admin-app` job, identical in shape to `acs-app`/`patient-app`. + ### `spec/` Product/architecture source of truth (PRD, UX flows, LGPD design, stack decisions) plus static HTML prototypes under `spec/ui_acs/` and `spec/ui_paciente/` — these are the visual reference for building out the real Flutter screens, not live code. @@ -112,6 +181,7 @@ Product/architecture source of truth (PRD, UX flows, LGPD design, stack decision - Prefer simple, predictable solutions aligned with the stack already chosen (Flutter + Dart backend + Postgres + MQTT) over introducing new frameworks/services not in `spec/stack.md`. - Keep triage/prioritization logic deterministic and consistent with the Manchester Protocol model referenced in the PRD — do not make risk classification probabilistic or user-overridable. - When touching sync behavior (backend `SyncFsm` or the ACS `offline_visit_queue.dart`), preserve retry/queue/conflict semantics — offline-first correctness is the primary architectural risk called out in `AGENTS.md`. +- When reusing a clinical fill color (`red`/`accent`/`danger`/`yellow`/`green`) as text or icon color in the Flutter apps, use the `*OnSurface` token and measure contrast against the surface it actually renders on (commonly `Card`/`surfaceRaised`), not the Scaffold background — see the WCAG contrast tokens note above and `spec/ux_accessibility_assessment.md`. - Never commit real patient data, credentials, or the dev Docker Compose secrets into anything beyond local development. ## graphify diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 36dbcd1..b5e4e28 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -115,9 +115,15 @@ flutter test Para executar um aplicativo em dispositivo ou emulador: ```bash -flutter run +./scripts/dev/run_acs.sh # ACS: preenche os dart-defines a partir do .env +cd apps/patient && flutter run ``` +O ACS precisa do script: a senha do broker é constante de compilação, sem valor +padrão, e é gerada por máquina. Um `flutter build apk` puro **falha** — a +guarda vive em `apps/acs/android/app/build.gradle.kts` — em vez de compilar em +silêncio um APK que nunca recebe alerta. + ## Alterações no backend O backend é um workspace Serverpod. Mantenha lógica de negócio em `application/` diff --git a/PROGRESS.md b/PROGRESS.md index 3d024b5..15167f1 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -92,12 +92,28 @@ Os cenários de transição e conflito foram validados em [backend/test/sync_fsm ### M1.5 - Criptografia local com SQLCipher -A camada de banco local criptografado foi adicionada em: +> **Correção de registro.** Esta entrada afirmava que o milestone estava +> concluído porque a classe `EncryptedLocalDatabase` existia. Ela existia, mas +> **nenhum código de produção a chamava**: o único chamador em todo o +> repositório era o teste unitário, com uma passphrase literal. Pior, o +> "fallback FFI para ambientes de teste/VM" abria o banco **sem criptografia +> nenhuma**, e era justamente o caminho que o CI (Linux) exercitava — o teste +> chamado "deve abrir banco criptografado" não provava nada do que o nome +> prometia. O repositório declarava uma propriedade de segurança que não tinha. -- [apps/patient/lib/core/database/encrypted_database.dart](apps/patient/lib/core/database/encrypted_database.dart) -- [apps/acs/lib/core/database/encrypted_database.dart](apps/acs/lib/core/database/encrypted_database.dart) +O milestone passou a ser real: -A implementação usa SQLCipher para plataformas móveis e fallback FFI para ambientes de teste/VM, preservando a exigência de proteção de dados sensíveis em repouso. +- [apps/acs/lib/core/security/database_key_store.dart](apps/acs/lib/core/security/database_key_store.dart) — a chave de 256 bits vive no Android Keystore / iOS Keychain, nunca no código. +- [apps/acs/lib/core/database/encrypted_database.dart](apps/acs/lib/core/database/encrypted_database.dart) — fora de Android/iOS a abertura **lança**, a menos que se passe `allowUnencryptedForTesting`, flag de nome deliberadamente constrangedor que só os testes de VM usam. +- [apps/acs/lib/core/database/sqlcipher_visit_store.dart](apps/acs/lib/core/database/sqlcipher_visit_store.dart) — o consumidor real: a fila de visitas offline, que antes vivia só em memória e perdia o trabalho de campo ao fechar o app. +- [apps/acs/integration_test/encrypted_storage_test.dart](apps/acs/integration_test/encrypted_storage_test.dart) — a prova, **em dispositivo**: lê o arquivo cru e afirma que ele não começa com `SQLite format 3` nem contém o conteúdo da visita. Verificado em emulador, inclusive por falsificação (removendo o SQLCipher, o teste falha). + +A cópia do app do paciente foi removida: era código morto, sem chamador, que +afirmava uma garantia que não entregava. + +Permanece fora: chave derivada de PIN/biometria (PRD 4.2.3) — não há fluxo de PIN +nos apps, e a interface de custódia aceita esse segundo fator depois sem migrar +dados. ## Milestones Técnicos - Fase 2 @@ -108,7 +124,7 @@ A implementação usa SQLCipher para plataformas móveis e fallback FFI para amb | M2.3 | MQTT com TLS | Parcialmente implementado | [apps/acs/lib/core/services/mqtt_secure_client.dart](apps/acs/lib/core/services/mqtt_secure_client.dart) adiciona configuração segura e payload de alerta com TLS/WSS e teste em [apps/acs/test/mqtt_secure_client_test.dart](apps/acs/test/mqtt_secure_client_test.dart) | | M2.4 | Sincronização Offline-First | Implementado | [apps/acs/lib/core/services/offline_visit_queue.dart](apps/acs/lib/core/services/offline_visit_queue.dart) com lote, retry e conflito, validado em [apps/acs/test/login_flow_test.dart](apps/acs/test/login_flow_test.dart) | | M2.5 | Testes de Caos (Toxiproxy) | Implementado | [apps/acs/lib/core/services/network_chaos_simulator.dart](apps/acs/lib/core/services/network_chaos_simulator.dart) e [apps/acs/test/network_chaos_test.dart](apps/acs/test/network_chaos_test.dart) simulam latência, jitter e retry em cenários de falha | -| M2.6 | Testes de Usabilidade | Implementado | [apps/acs/test/login_flow_test.dart](apps/acs/test/login_flow_test.dart) e [apps/patient/test/patient_app_mvp_test.dart](apps/patient/test/patient_app_mvp_test.dart) validam labels semânticas e área mínima de toque para os principais botões | +| M2.6 | Testes de Usabilidade e Acessibilidade | Implementado | [spec/ux_accessibility_assessment.md](spec/ux_accessibility_assessment.md) — matriz de contraste WCAG 1.4.3 determinística (`contrast_tokens_test.dart`), `meetsGuideline` de contraste/alvo de toque e `liveRegion` (SC 4.1.3) em [apps/acs/test/login_flow_test.dart](apps/acs/test/login_flow_test.dart) e [apps/patient/test/patient_app_mvp_test.dart](apps/patient/test/patient_app_mvp_test.dart), validado ponta a ponta no emulador contra o backend e o broker reais | ## O que já está pronto - Fase 2 @@ -161,6 +177,258 @@ A fila local de visitas foi evoluída em [apps/acs/lib/core/services/offline_vis A validação foi incluída em [apps/acs/test/login_flow_test.dart](apps/acs/test/login_flow_test.dart), cobrindo o fluxo de sucesso e o caso de conflito com reprocessamento. +#### A fila passou a sair do aparelho + +O `BackendVisitSynchronizer` existia e era testado, mas **não era injetado**: o app +montava a fila sem ele, `sync()` caía no ramo sem remetente e devolvia erro. Na +prática as visitas nunca subiam, e a retenção — `SqlCipherVisitStore.save()` apaga +do disco tudo que saiu da lista de pendentes — nunca disparava em produção. + +Nenhum teste podia ver isso: a UI só é testável com a fila injetada, então a +montagem real nunca era exercitada. A fiação foi extraída para +[apps/acs/lib/core/services/visit_queue_factory.dart](apps/acs/lib/core/services/visit_queue_factory.dart) +e ganhou teste próprio. + +Para ligar o sincronizador, o registro passou a guardar `patientId` em vez de +`patientName`. O rótulo antigo era `'Paciente ' + 8 dos 32 dígitos do UUID`: +irreversível, então o servidor recusaria a visita por identificador inválido — e +era texto legível sobre a pessoa num disco que não precisava dele. Agora o +identificador vai ao banco e o rótulo é montado na tela (minimização, +LGPD-RF01). Consequência de produto: a aba "Visita" sem alerta selecionado não +grava mais, porque sem alerta não há paciente. + +O schema local subiu para v2 (`patient_id`), com `onUpgrade` que **recria** a +tabela — nem `local_queue` nem a `offline_visits` v1 guardavam o UUID. Isso perde +visitas pendentes gravadas antes da atualização, que de qualquer forma o servidor +recusaria. **A partir do primeiro release real, essa migração precisa preservar +dados.** + +A tela ganhou contador de pendentes/conflitos e o botão "Sincronizar agora" — o +gatilho é manual, para o ACS decidir quando gastar dados em campo. + +Dois defeitos vizinhos apareceram no caminho e foram corrigidos: `sync()` +devolvia `synced` quando o servidor recusava uma visita (o status `error` caía no +ramo `default`), o que a prendia na fila em silêncio; e `visits.sync` reportava +"este alerta não pertence à sua microárea" para erro de sessão. + +#### O app passou a dizer o que está errado + +Dois defeitos vizinhos, com a mesma forma: o app sabia e não contava. + +O painel tinha **um slot de banner para dois estados** (`_feedError ?? _storageError`). +Em campo o broker e o armazenamento caem juntos, e o `??` sempre mostrava o do +broker: o ACS via "sem conexão com a central" e nunca descobria que as visitas +do dia não estavam sendo salvas. O subtítulo era fixo, então quando o banner +exibido era o de disco ele ainda afirmava algo sobre alertas. E o aviso de +persistência era calculado uma vez, no `initState` — falhas posteriores de +gravação ficavam invisíveis. Agora são dois banners independentes, cada um com +seu texto, e o de persistência é lido do estado corrente da fila a cada build. +A tela de visita ganhou o mesmo aviso inline: é onde a pessoa acabou de gravar. + +Os avisos de infraestrutura passaram a usar o azul de destaque. `docs/telas-acs.md` +reserva a cor para a gravidade clínica, e um card vermelho de falha técnica +competia com o alerta vermelho de um paciente na mesma lista. + +O segundo defeito: **`SINALACS_MQTT_PASSWORD` tinha um default que nunca +funcionou**. O broker cria `acs-area-12` com `MQTT_ACS_PASSWORD`, segredo +aleatório por máquina, então nenhum valor embutido no código poderia acertá-lo — +e toda a documentação mandava rodar `flutter run` sem `--dart-define` nenhum. O +resultado era um app que nunca recebia alerta e dizia apenas "sem conexão". + +- O default saiu. Vazio virou estado detectável, e a tela diz que o aplicativo + foi compilado sem a senha. +- `scripts/dev/run_acs.sh` lê o `.env`, roda o `sync_dev_ca.sh` (a CA é asset + gitignored que o build exige) e preenche os quatro dart-defines. +- As falhas do feed viraram `AlertFeedFailure` classificada. Senha ausente, CA + ausente, credencial recusada e broker inalcançável eram a mesma frase; o + `mqtt_client` já trazia o motivo no CONNACK e o código o descartava, junto com + o próprio erro, que agora vai para `dart:developer`. + +Verificado no emulador, os quatro caminhos: compilado sem senha, compilado pelo +script, senha errada e broker parado — cada um com sua mensagem. + +#### O app desistia do broker na primeira tentativa + +`_connectFeed()` rodava **uma vez**, no `initState`. Se falhasse, o app nunca +mais tentava — o banner ficava na tela até alguém fechar e reabrir o +aplicativo. O `autoReconnect` do `mqtt_client` não cobria isso: ele só age +**depois** de uma conexão bem-sucedida, e as duas rotas de erro do cliente +chamam `disconnect()`, que o desliga de propósito para o cliente não ficar +órfão tentando para sempre. + +Em campo isso significava um ACS que abre o app na zona rural sem sinal ficar +sem alerta pelo resto do turno, mesmo com o sinal voltando cinco minutos +depois — e como a sessão MQTT é persistente, o broker estava guardando esses +alertas com QoS 1 o tempo todo, só esperando uma conexão que nunca vinha. + +`AcsHomeShell` passou a retentar sozinho quando a falha é **transitória** +(broker inalcançável, ou `brokerUnavailable` do CONNACK — nunca senha ausente, +CA ausente, ou credencial/identificador recusado, que não mudam sozinhos): +backoff de 2s a 60s em [reconnect_schedule.dart](apps/acs/lib/core/services/reconnect_schedule.dart), +os mesmos valores do `MqttAlertDispatcher` do backend. O banner ganhou "Tentar +agora" para quem já vê o sinal voltar, e voltar do segundo plano dispara uma +tentativa imediata — é o gatilho que mais importa, porque o sinal costuma +voltar com a tela apagada. + +Defeito vizinho, a outra metade do mesmo problema: o chip do cabeçalho também +era escrito uma única vez. Uma queda **depois** de uma conexão bem-sucedida +nunca chegava a ele, que continuava dizendo "em linha" para sempre enquanto o +`autoReconnect` trabalhava por baixo em silêncio. `AlertFeed.onConnectionChanged` +subiu para a interface como campo mutável para o shell poder assinar mudanças +de estado a qualquer momento, não só no retorno do `start()`. + +#### `flutter build apk` puro ainda entregava um APK que nunca conectava + +Os dois defeitos acima foram fechados, mas sobrava uma lacuna: mesmo sem +`SINALACS_MQTT_PASSWORD`, `flutter build apk` compilava normalmente. O defeito +só se denunciava em tempo de execução, pelo banner "compilado sem a senha" — +tarde demais para quem já distribuiu o APK. + +[apps/acs/android/app/build.gradle.kts](apps/acs/android/app/build.gradle.kts) +ganhou uma guarda em `doFirst` das tarefas `compileFlutterBuild*`: decodifica a +propriedade `dart-defines` (o Flutter Gradle Plugin já lê essa mesma +propriedade) e falha, com o comando certo, se `SINALACS_MQTT_PASSWORD` não +estiver lá. Precisou ser `doFirst` de tarefa, e não bloco de configuração — +senão dispararia em todo `gradlew`, inclusive o sync do Android Studio, que não +passa define nenhum. Escotilha explícita para quem quer de propósito um APK +sem senha (por exemplo, para reproduzir o banner): +`-Psinalacs.allowMissingMqttPassword=true`, no molde constrangedor-de-digitar +de `allowUnencryptedForTesting`. + +Aproveitado para tirar a senha do `argv`: `run_acs.sh` passou de `--dart-define` +para `--dart-define-from-file`, com um arquivo temporário (`mktemp`, 0600) que +um `trap` apaga ao sair. A troca exigiu remover o `exec` das duas chamadas ao +`flutter` — `exec` substitui o processo do shell, e o `trap` nunca rodaria, +deixando o arquivo com a senha esquecido em `/tmp` depois de cada execução. +`scripts/qa/e2e.sh` continua passando a senha por `argv`: aquele caminho roda +`flutter test`/`dart run`, não `flutter build`, e não passa pela guarda. + +#### O registro de visitas não tinha caminho algum na operação real + +`alerts.createRedAlert` é o único produtor de alertas, e crava sempre +`riskLevel: 'red'` — emergência, com SAMU. O cartão do painel tinha **um** +botão, mutuamente exclusivo entre "Acionar SAMU / Atender" e "Iniciar rota de +visita" conforme o risco; como só chega vermelho, o segundo era código morto em +produção — só alcançável injetando um alerta amarelo à mão no broker. Com +`patientId` obrigatório desde a fiação da sincronização, e sem nenhum alerta +não-vermelho para habilitar o formulário, a aba "Visita" ficou inalcançável: o +PRD mede engajamento do ACS em **≥ 8 visitas/dia**, e visita de rotina — o +padrão de uso real — não tinha de onde partir. + +Dois caminhos, não um. O reativo já tinha meio-caminho andado: a tela de +escalonamento ganhara, numa mudança anterior não documentada aqui, um segundo +botão "Iniciar rota de visita" (`Key('escalation_visit')`) com o aviso "a +visita é acompanhamento do caso e não substitui o acionamento do SAMU" — o ACS +aciona o SAMU primeiro, visita depois. Verificado que já funciona ponta a +ponta; nada mexido ali. + +O que faltava era o de rotina: `patients.listMicroArea` (backend, novo) lista +os pacientes da microárea do ACS — a microárea vem do token, nunca de um +parâmetro, e a consulta é um JOIN em duas etapas +(`OrmPatientDirectoryStore`, `backend/sinalacs_server/lib/src/infrastructure/database/`) +porque `Patient` não guarda microárea: ela vive em `users`, e `Patient.id` É o +UUID do usuário. O payload é só nome e condições crônicas — o que +`spec/lgpd_design.md` autoriza para visita de rotina, nada além. +`VisitRegistrationScreen` ganhou o seletor (`Key('patient_picker')`): sem +alerta selecionado, busca por nome e uma lista; escolher libera o formulário +exatamente como um alerta faria. O nome vive só em memória, para o rótulo — +`OfflineVisitRecord` continua carregando apenas o UUID, mesma disciplina já +estabelecida para o caminho por alerta. + +Dois furos adjacentes fechados no caminho, achados ao implementar o diretório: + +- **Territorialização do sync, furo do INV-01.** `VisitSyncService` validava + que o ACS é territorializado, mas nunca que o PACIENTE pertence ao mesmo + território — qualquer UUID de paciente existente era aceito, de qualquer + microárea. `VisitStore.microAreaOfPatient` fecha isso; a recusa é por visita + (na época, `SyncStatus.error` — virou `SyncStatus.rejected`, terminal, numa + mudança posterior, ver abaixo), não descarta o resto do lote. +- **`audit_logs` era tabela morta.** Existia desde a migração-base, + documentada como "trilha de auditoria de acesso a dados sensíveis + append-only", e nenhuma linha de código escrevia nela — este PR introduzia a + primeira leitura em massa de PHI do sistema. `AuditTrail` + (`backend/sinalacs_server/lib/src/application/audit/`) liga os dois pontos + que este PR cria: a leitura da lista de pacientes (evento, sem enumerar quem + foi lido — listar recriaria o prontuário dentro do próprio log) e a recusa + por território (com o UUID do paciente envolvido). `ipHash` nunca é IP em + claro — SHA-256 sobre `request.remoteInfo`, que o próprio Serverpod já + resolve corretamente atrás do Traefik (prefere `Forwarded`/`X-Forwarded-For` + antes do endereço da conexão). A escrita é best-effort: uma trilha fora do ar + não pode impedir o ACS de trabalhar, só faz o processo logar a falha. A + assinatura em hash chain que `spec/lgpd_design.md` (LGPD-RT03) descreve + continua não implementada, e os demais endpoints sensíveis (alertas, ack, + triagem) ainda não escrevem na trilha. + +Seed de desenvolvimento ganhou cinco pacientes sintéticos (nomes obviamente +fictícios) na microárea do ACS, mais um sexto fora dela — para o seletor ser +demonstrável e para provar territorialização sem precisar de outra stack de +teste. + +Verificado no emulador, contra a stack local rodando de verdade: o seletor +lista os pacientes reais do Postgres; escolher um e sincronizar grava +`syncStatus: synced` no servidor; o arquivo do banco cifrado, puxado do +aparelho depois de gravar e sincronizar pelo seletor, não contém o nome em +nenhum ponto (nem o logcat); uma visita para paciente de outra microárea volta +como `error` (na época — ver abaixo) e grava a linha `denied_territory` em +`audit_logs`, sem tocar `visits`; o caminho por alerta (vermelho → SAMU → +escalonamento → visita) continua idêntico ao de antes desta mudança. + +#### A cadeia de hash da trilha de auditoria, e a recusa que ficava presa na fila para sempre + +Duas dívidas registradas explicitamente no PR anterior, fechadas nesta mudança. + +**A cadeia de hash de `audit_logs` (LGPD-RT03).** A trilha ganhara escritores +no PR anterior, mas só fazia `insertRow` — qualquer um com acesso de escrita ao +Postgres editava ou apagava uma linha sem deixar rastro, o que não serve ao +não-repúdio que a spec promete. `audit_logs` ganhou três colunas: `sequence` +(posição, contígua, índice único), `previousHash` (o `entryHash` da linha +anterior, ou `AuditChain.genesisHash` — 64 zeros — na primeira) e `entryHash` +(HMAC-SHA256 do conteúdo da linha). A chave é um segredo PRÓPRIO +(`AUDIT_CHAIN_SECRET`), nunca derivado do `JWT_SECRET`: os dois precisam poder +rotacionar de forma independente, e SHA-256 sem chave não detectaria uma +reescrita completa por quem tem acesso de escrita ao banco — exatamente o +adversário que a §458 de `spec/lgpd_design.md` descreve. +`OrmAuditTrail.record` agora lê a última linha e insere a próxima dentro da +MESMA transação, sob `pg_advisory_xact_lock`, para duas gravações concorrentes +não lerem a mesma linha anterior e bifurcarem a cadeia. `AuditChainVerifier` (e +o `bin/audit_chain_check.dart` que o expõe como script) reconstrói a cadeia +inteira e detecta edição, remoção ou reordenação de qualquer linha — verificado +ao vivo: adulterar uma linha por `UPDATE` direto no Postgres faz o verificador +falhar exatamente na `sequence` afetada. Fora de escopo, registrado na spec: o +append-only em si não é imposto pelo banco (sem trigger/`REVOKE`) — a cadeia +*detecta* a violação, não a impede. + +**Recusa definitiva presa na fila para sempre.** `VisitSyncService._syncOne` +colapsava seis motivos de falha distintos no mesmo `SyncStatus.error`, e +`OfflineVisitQueue._applyOutcomes` reenfileirava `error` incondicionalmente. +Para a recusa territorial isso era retentativa eterna garantida: a checagem de +território roda antes do lookup por `localId`, então o reenvio falha de forma +idêntica para sempre, e nada no aparelho explicava por quê. `SyncStatus` ganhou +`rejected`, terminal: localId vazio, UUID malformado, território incompatível e +visita de outro agente agora retornam `rejected` (só "paciente não encontrado" +continua `error` — pode ser cadastrado depois, é legitimamente retentável). +`SyncFsm` ganhou o estado espelhado, sem transição de saída. No aparelho, +`OfflineVisitRecord` ganhou `rejectionReason`; a fila ganhou uma quarta lista +(`_rejected`, ao lado de pendente/sincronizada/conflito) que sai da retentativa +mas continua no disco — `VisitStore.save` passou a persistir pendentes MAIS +recusadas, não só pendentes, senão a recusada sumiria do aparelho no instante +da recusa, antes de o ACS decidir. A tela ganhou o contador +(`Key('rejected_visits_count')`) e um botão de descarte com confirmação +(`Key('discard_rejected')`) — nada remove a recusada do aparelho sem essa +confirmação explícita. `encrypted_database.dart` foi de v3 a v4 com +`ALTER TABLE ... ADD COLUMN rejection_reason` — a primeira migração aditiva +desde que o schema existe; as anteriores (v1 → v2) recriavam a tabela porque o +app ainda não tinha tido release. + +Verificação: 83 testes no backend (69 unit + 14 integração, incluindo a +gravação real de duas linhas encadeadas contra Postgres e a checagem do +`pg_advisory_xact_lock` — a cadeia de hash tem cobertura própria em +`test/unit/audit_chain_test.dart`, incluindo detecção de edição, remoção, +renumeração e segredo errado), 89 testes herméticos no app ACS (6 novos: +recusa saindo da fila sem reenviar, precedência sobre conflito, `discardRejected`, +persistência da recusada em disco, migração v3 → v4 preservando linhas, e o +fluxo completo de descarte com confirmação na tela). + ### M2.5 - Testes de Caos Foi adicionada a simulação de degradação de rede em [apps/acs/lib/core/services/network_chaos_simulator.dart](apps/acs/lib/core/services/network_chaos_simulator.dart): @@ -175,13 +443,33 @@ Os cenários de falha foram validados em [apps/acs/test/network_chaos_test.dart] ### M2.6 - Testes de Usabilidade e Acessibilidade -Foi implementada a validação de UX básica em [apps/acs/test/login_flow_test.dart](apps/acs/test/login_flow_test.dart) e [apps/patient/test/patient_app_mvp_test.dart](apps/patient/test/patient_app_mvp_test.dart): - -- rótulos semânticos para leitores de tela -- mínimo de 48x48 dp nos principais botões de ação -- manutenção do fluxo principal logo após a validação de acessibilidade - -O app também foi ajustado para expor essas metas corretamente em [apps/acs/lib/app/app.dart](apps/acs/lib/app/app.dart) e [apps/patient/lib/app/app.dart](apps/patient/lib/app/app.dart). +[spec/ux_accessibility_assessment.md](spec/ux_accessibility_assessment.md) documenta a auditoria +completa contra a baseline WCAG 2.1 AA do PRD (§4.3). A primeira versão do relatório media +contraste manualmente contra o fundo do `Scaffold`, mas texto de risco/status é renderizado +dentro de `Card` — produziu um falso positivo e deixou passar duas falhas piores (vermelho e azul +de preenchimento usados como cor de texto, abaixo de 4.5:1 sobre o card). A revisão trocou a +medição manual por uma matriz determinística +(`apps/{acs,patient}/test/contrast_tokens_test.dart`), corrigiu os tokens separando cor de +PREENCHIMENTO de cor de TEXTO (`redOnSurface`/`accentOnSurface` no ACS, +`dangerOnSurface`/`accentOnSurface` no paciente, em +[apps/acs/lib/app/acs_theme.dart](apps/acs/lib/app/acs_theme.dart) e +[apps/patient/lib/app/patient_theme.dart](apps/patient/lib/app/patient_theme.dart)), e adicionou: + +- `meetsGuideline(textContrastGuideline/androidTapTargetGuideline/labeledTapTargetGuideline)` em + [apps/acs/test/login_flow_test.dart](apps/acs/test/login_flow_test.dart) e + [apps/patient/test/patient_app_mvp_test.dart](apps/patient/test/patient_app_mvp_test.dart) +- alvo de toque de 60x60 dp no botão "Ligar para o SAMU (192)", que não tinha `minimumSize` + (default de 40dp de altura visual — a medição anterior de "48x52 dp" estava incorreta) +- `Semantics(liveRegion: true)` em sete pontos de status dinâmico (WCAG 4.1.3, critério ausente + da avaliação original), incluindo a confirmação do alerta de emergência do paciente +- o cartão de alerta da fila do ACS passou a ser lido como uma frase única pelo leitor de tela, + em vez de nós soltos + +Validado ponta a ponta no emulador Android (`emulator-5554`): o app Paciente disparou um alerta de +emergência real contra o backend em Docker Compose, e o app ACS recebeu pelo broker MQTT/TLS real, +exibindo o novo contraste, o botão do SAMU no novo tamanho e o risco traduzido corretamente. Os 14 +testes de integração em dispositivo de `apps/acs/integration_test/` (inclusive o que lê o arquivo +do banco criptografado) passam sobre o código revisado. ## Preparação de deploy — piloto em serviços free-tier (backend) @@ -252,7 +540,7 @@ deve ser usado com dados reais de pacientes. - [x] M1.2 - CI básica - [x] M1.3 - Motor de triagem - [x] M1.4 - FSM de sincronização -- [x] M1.5 - SQLCipher local +- [x] M1.5 - SQLCipher local (ver a correção de registro na seção M1.5) ### Fase 2 @@ -354,9 +642,10 @@ O backend `dart:io` foi removido da árvore; o histórico do git o preserva. - **Autenticação institucional.** O acesso segue sendo o token HMAC de desenvolvimento, gated por `ENABLE_DEV_LOGIN`. Gov.br e matrícula da Secretaria continuam não implementados. -- **Apps Flutter não consomem o cliente gerado.** `sinalacs_client` existe e é - publicado, mas `apps/acs` e `apps/patient` seguem com dados locais — e por isso - o `RiskLevel` ainda não atravessa a fronteira na prática. +- ~~**Apps Flutter não consomem o cliente gerado.**~~ Desatualizado: os dois + apps já consomem `sinalacs_client` por dependência de caminho e falam com o + backend real (ver M2.4 acima) — `RiskLevel` atravessa a fronteira desde a + triagem. - **Risco residual de entrega.** MQTT não participa da transação: se a publicação tem êxito e o commit falha, o alerta chega ao ACS sem linha no banco. Raro e erra para o lado seguro quanto à INV-03; fechar por completo exigiria outbox diff --git a/README.md b/README.md index 0b228c6..c48db7c 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Consulte [PROGRESS.md](PROGRESS.md) para o status detalhado dos milestones e apps/ acs/ Aplicativo Flutter do Agente Comunitário de Saúde patient/ Aplicativo Flutter do paciente - admin/ Base do aplicativo administrativo + admin/ Backoffice administrativo (Flutter Web e Android) backend/ Backend Dart (dart:io, sem framework) e regras de domínio infra/ Configuração local de infraestrutura spec/ PRD, UX, privacidade e fluxos do produto @@ -42,7 +42,7 @@ tests/ Testes compartilhados ## Pré-requisitos - Flutter SDK compatível com Dart `>=3.3.0 <4.0.0`. -- Android SDK com API 36 e JDK 17 para gerar ou executar o app ACS no Android. +- Android SDK com API 36 e JDK 17 para gerar ou executar os apps ACS, paciente e admin no Android. - Docker Engine com Docker Compose v2 para subir a stack local. - Um emulador Android ou dispositivo físico, opcional para execução mobile. @@ -76,31 +76,45 @@ Para encerrar a stack: docker compose down ``` -O Compose atual usa credenciais de desenvolvimento declaradas no -[docker-compose.yml](docker-compose.yml). Não reutilize essas credenciais nem -habilite o dashboard inseguro do Traefik em ambientes públicos. +O Compose lê todas as credenciais do `.env` gerado por +[scripts/dev/bootstrap_env.sh](scripts/dev/bootstrap_env.sh) — cada máquina tem +as suas. Não reutilize credenciais de desenvolvimento nem habilite o dashboard +inseguro do Traefik em ambientes públicos. ### Aplicativo ACS ```bash -cd apps/acs -flutter pub get -flutter run +cd apps/acs && flutter pub get && cd - +./scripts/dev/run_acs.sh ``` -Para selecionar explicitamente um emulador Android disponível: +Use o script, não `flutter run` direto. A senha do broker é resolvida em tempo +de compilação e não tem valor padrão: ela é gerada por máquina pelo +`bootstrap_env.sh`. O script lê o `.env`, copia a CA do broker para os assets e +passa os quatro `--dart-define` por um arquivo temporário (`--dart-define-from-file`, +apagado ao sair), para a senha não trafegar na linha de comando do `flutter`. Um +`flutter build apk` sem essas variáveis **falha** — a guarda vive em +`apps/acs/android/app/build.gradle.kts` — em vez de compilar em silêncio um APK +que nunca recebe alerta. + +Para escolher o dispositivo, ou gerar o APK: ```bash flutter devices -flutter run -d +./scripts/dev/run_acs.sh -d +./scripts/dev/run_acs.sh --build ``` ### Aplicativo do paciente +O paciente não usa MQTT: o default de `SINALACS_HOST` já serve no emulador. + ```bash cd apps/patient flutter pub get flutter run +# em aparelho físico, apontando para a máquina da stack: +flutter run --dart-define=SINALACS_HOST=http://:8080/ ``` ## Build @@ -110,10 +124,8 @@ flutter run O app ACS foi validado com `compileSdk` e `targetSdk` 36. Para gerar o APK: ```bash -cd apps/acs -flutter clean -flutter pub get -flutter build apk --debug +cd apps/acs && flutter clean && flutter pub get && cd - +./scripts/dev/run_acs.sh --build ``` O artefato é criado em: @@ -157,16 +169,40 @@ requests: `serverpod-backend` (sobe o Postgres de teste e roda `dart analyze` mais a suíte completa), `backend-docker-build` (valida que a imagem builda), `patient-app` e `acs-app`. +### Configuração + +**Antes do primeiro `docker compose up`, gere a configuração local:** + +```bash +./scripts/dev/bootstrap_env.sh +``` + +O script cria `.env` com segredos aleatórios desta máquina (senha do Postgres, +as duas do broker MQTT e o `JWT_SECRET`) e gera +`backend/sinalacs_server/config/passwords.yaml`, que é gitignored e por isso não +existe num clone limpo — sem ele a suíte de testes do Serverpod morre sem +imprimir nada. Nenhum dos dois entra no git. + +[.env.example](.env.example) é a referência completa de todas as variáveis, com +um comentário por bloco dizendo quem consome cada uma. O `docker-compose.yml` +declara cada segredo como `${VAR:?...}`: se faltar, o Compose falha dizendo qual +variável está ausente, em vez de subir com uma senha embutida no arquivo +versionado. + Configuração de servidor e banco vem dos arquivos `sinalacs_server/config/*.yaml` e pode ser sobrescrita por variáveis de ambiente: `SERVERPOD_DATABASE_HOST` e companhia, `SERVERPOD_APPLY_MIGRATIONS` (aplica as migrações no boot), `SERVERPOD_REDIS_ENABLED` (Redis é opcional e fica desligado) e `SERVERPOD_INSIGHTS_SERVER_PORT`. O MQTT não faz parte do Serverpod e mantém as próprias variáveis, lidas por `sinalacs_server/lib/src/config/app_config.dart`: -`MQTT_BROKER`/`MQTT_USERNAME`/`MQTT_PASSWORD`/`MQTT_USE_TLS`, mais `JWT_SECRET`, -`APP_ENV` (`production` exige `JWT_SECRET`, falhando rápido no boot) e -`ENABLE_DEV_LOGIN` (por padrão desligado — sem ele, `auth.developmentLogin` -falha como se o endpoint não existisse). Veja +`MQTT_BROKER`/`MQTT_USERNAME`/`MQTT_PASSWORD`/`MQTT_USE_TLS`/`MQTT_CA_CERT_PATH`, +mais `JWT_SECRET`, `APP_ENV` e `ENABLE_DEV_LOGIN` (por padrão desligado — sem +ele, `auth.developmentLogin` falha como se o endpoint não existisse). + +Fora de `development`, o servidor **recusa subir** se `JWT_SECRET` estiver +ausente, vazio ou igual ao valor de desenvolvimento (que é público, por estar no +código versionado). O token carrega o papel e a microárea, então assinar com uma +chave conhecida permitiria forjar um acesso de ACS a qualquer território. Veja [backend/DEPLOY.md](backend/DEPLOY.md) para o runbook completo do piloto de deploy free-tier. diff --git a/apps/acs/.flutter-plugins-dependencies b/apps/acs/.flutter-plugins-dependencies index c2a4cc1..45ffef8 100644 --- a/apps/acs/.flutter-plugins-dependencies +++ b/apps/acs/.flutter-plugins-dependencies @@ -1 +1 @@ -{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"connectivity_plus","path":"/home/codespace/.pub-cache/hosted/pub.dev/connectivity_plus-7.3.1/","native_build":true,"dependencies":[]},{"name":"geolocator_apple","path":"/home/codespace/.pub-cache/hosted/pub.dev/geolocator_apple-2.3.14/","shared_darwin_source":true,"native_build":true,"dependencies":[]},{"name":"google_maps_flutter_ios","path":"/home/codespace/.pub-cache/hosted/pub.dev/google_maps_flutter_ios-2.15.2/","native_build":true,"dependencies":[]},{"name":"sqflite_darwin","path":"/home/codespace/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/","shared_darwin_source":true,"native_build":true,"dependencies":[]},{"name":"sqflite_sqlcipher","path":"/home/codespace/.pub-cache/hosted/pub.dev/sqflite_sqlcipher-3.2.0/","native_build":true,"dependencies":[]}],"android":[{"name":"connectivity_plus","path":"/home/codespace/.pub-cache/hosted/pub.dev/connectivity_plus-7.3.1/","native_build":true,"dependencies":[]},{"name":"flutter_plugin_android_lifecycle","path":"/home/codespace/.pub-cache/hosted/pub.dev/flutter_plugin_android_lifecycle-2.0.26/","native_build":true,"dependencies":[]},{"name":"geolocator_android","path":"/home/codespace/.pub-cache/hosted/pub.dev/geolocator_android-4.6.2/","native_build":true,"dependencies":[]},{"name":"google_maps_flutter_android","path":"/home/codespace/.pub-cache/hosted/pub.dev/google_maps_flutter_android-2.14.13/","native_build":true,"dependencies":["flutter_plugin_android_lifecycle"]},{"name":"sqflite_android","path":"/home/codespace/.pub-cache/hosted/pub.dev/sqflite_android-2.4.0/","native_build":true,"dependencies":[]},{"name":"sqflite_sqlcipher","path":"/home/codespace/.pub-cache/hosted/pub.dev/sqflite_sqlcipher-3.2.0/","native_build":true,"dependencies":[]}],"macos":[{"name":"connectivity_plus","path":"/home/codespace/.pub-cache/hosted/pub.dev/connectivity_plus-7.3.1/","native_build":true,"dependencies":[]},{"name":"geolocator_apple","path":"/home/codespace/.pub-cache/hosted/pub.dev/geolocator_apple-2.3.14/","shared_darwin_source":true,"native_build":true,"dependencies":[]},{"name":"sqflite_darwin","path":"/home/codespace/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/","shared_darwin_source":true,"native_build":true,"dependencies":[]},{"name":"sqflite_sqlcipher","path":"/home/codespace/.pub-cache/hosted/pub.dev/sqflite_sqlcipher-3.2.0/","native_build":true,"dependencies":[]}],"linux":[{"name":"connectivity_plus","path":"/home/codespace/.pub-cache/hosted/pub.dev/connectivity_plus-7.3.1/","native_build":false,"dependencies":[]}],"windows":[{"name":"connectivity_plus","path":"/home/codespace/.pub-cache/hosted/pub.dev/connectivity_plus-7.3.1/","native_build":true,"dependencies":[]},{"name":"geolocator_windows","path":"/home/codespace/.pub-cache/hosted/pub.dev/geolocator_windows-0.2.5/","native_build":true,"dependencies":[]}],"web":[{"name":"connectivity_plus","path":"/home/codespace/.pub-cache/hosted/pub.dev/connectivity_plus-7.3.1/","dependencies":[]},{"name":"geolocator_web","path":"/home/codespace/.pub-cache/hosted/pub.dev/geolocator_web-4.1.4/","dependencies":[]},{"name":"google_maps_flutter_web","path":"/home/codespace/.pub-cache/hosted/pub.dev/google_maps_flutter_web-0.5.12/","dependencies":[]}]},"dependencyGraph":[{"name":"connectivity_plus","dependencies":[]},{"name":"flutter_plugin_android_lifecycle","dependencies":[]},{"name":"geolocator","dependencies":["geolocator_android","geolocator_apple","geolocator_web","geolocator_windows"]},{"name":"geolocator_android","dependencies":[]},{"name":"geolocator_apple","dependencies":[]},{"name":"geolocator_web","dependencies":[]},{"name":"geolocator_windows","dependencies":[]},{"name":"google_maps_flutter","dependencies":["google_maps_flutter_android","google_maps_flutter_ios","google_maps_flutter_web"]},{"name":"google_maps_flutter_android","dependencies":["flutter_plugin_android_lifecycle"]},{"name":"google_maps_flutter_ios","dependencies":[]},{"name":"google_maps_flutter_web","dependencies":[]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]},{"name":"sqflite_sqlcipher","dependencies":[]}],"date_created":"2026-09-01 23:40:01.422052","version":"3.24.5","swift_package_manager_enabled":false} \ No newline at end of file +{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"connectivity_plus","path":"/home/rock/.pub-cache/hosted/pub.dev/connectivity_plus-7.3.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage","path":"/home/rock/.pub-cache/hosted/pub.dev/flutter_secure_storage-9.2.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_apple","path":"/home/rock/.pub-cache/hosted/pub.dev/geolocator_apple-2.3.14/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"google_maps_flutter_ios","path":"/home/rock/.pub-cache/hosted/pub.dev/google_maps_flutter_ios-2.18.6/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"integration_test","path":"/home/rock/flutter/packages/integration_test/","native_build":true,"dependencies":[],"dev_dependency":true},{"name":"path_provider_foundation","path":"/home/rock/.pub-cache/hosted/pub.dev/path_provider_foundation-2.5.1/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"/home/rock/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_sqlcipher","path":"/home/rock/.pub-cache/hosted/pub.dev/sqflite_sqlcipher-3.2.0/","native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"connectivity_plus","path":"/home/rock/.pub-cache/hosted/pub.dev/connectivity_plus-7.3.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_plugin_android_lifecycle","path":"/home/rock/.pub-cache/hosted/pub.dev/flutter_plugin_android_lifecycle-2.0.35/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage","path":"/home/rock/.pub-cache/hosted/pub.dev/flutter_secure_storage-9.2.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_android","path":"/home/rock/.pub-cache/hosted/pub.dev/geolocator_android-4.6.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"google_maps_flutter_android","path":"/home/rock/.pub-cache/hosted/pub.dev/google_maps_flutter_android-2.19.13/","native_build":true,"dependencies":["flutter_plugin_android_lifecycle"],"dev_dependency":false},{"name":"integration_test","path":"/home/rock/flutter/packages/integration_test/","native_build":true,"dependencies":[],"dev_dependency":true},{"name":"jni","path":"/home/rock/.pub-cache/hosted/pub.dev/jni-1.0.3/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni_flutter","path":"/home/rock/.pub-cache/hosted/pub.dev/jni_flutter-1.0.3/","native_build":true,"dependencies":["jni"],"dev_dependency":false},{"name":"path_provider_android","path":"/home/rock/.pub-cache/hosted/pub.dev/path_provider_android-2.3.1/","native_build":false,"dependencies":["jni","jni_flutter"],"dev_dependency":false},{"name":"sqflite_android","path":"/home/rock/.pub-cache/hosted/pub.dev/sqflite_android-2.4.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_sqlcipher","path":"/home/rock/.pub-cache/hosted/pub.dev/sqflite_sqlcipher-3.2.0/","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"connectivity_plus","path":"/home/rock/.pub-cache/hosted/pub.dev/connectivity_plus-7.3.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_macos","path":"/home/rock/.pub-cache/hosted/pub.dev/flutter_secure_storage_macos-3.1.3/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_apple","path":"/home/rock/.pub-cache/hosted/pub.dev/geolocator_apple-2.3.14/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/rock/.pub-cache/hosted/pub.dev/path_provider_foundation-2.5.1/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"/home/rock/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_sqlcipher","path":"/home/rock/.pub-cache/hosted/pub.dev/sqflite_sqlcipher-3.2.0/","native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"connectivity_plus","path":"/home/rock/.pub-cache/hosted/pub.dev/connectivity_plus-7.3.1/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_linux","path":"/home/rock/.pub-cache/hosted/pub.dev/flutter_secure_storage_linux-1.2.3/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/rock/.pub-cache/hosted/pub.dev/jni-1.0.3/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"/home/rock/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.2/","native_build":false,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"connectivity_plus","path":"/home/rock/.pub-cache/hosted/pub.dev/connectivity_plus-7.3.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_windows","path":"/home/rock/.pub-cache/hosted/pub.dev/flutter_secure_storage_windows-3.1.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_windows","path":"/home/rock/.pub-cache/hosted/pub.dev/geolocator_windows-0.2.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/rock/.pub-cache/hosted/pub.dev/jni-1.0.3/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"/home/rock/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[],"dev_dependency":false}],"web":[{"name":"connectivity_plus","path":"/home/rock/.pub-cache/hosted/pub.dev/connectivity_plus-7.3.1/","dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_web","path":"/home/rock/.pub-cache/hosted/pub.dev/flutter_secure_storage_web-1.2.1/","dependencies":[],"dev_dependency":false},{"name":"geolocator_web","path":"/home/rock/.pub-cache/hosted/pub.dev/geolocator_web-4.1.4/","dependencies":[],"dev_dependency":false},{"name":"google_maps_flutter_web","path":"/home/rock/.pub-cache/hosted/pub.dev/google_maps_flutter_web-0.6.3+1/","dependencies":[],"dev_dependency":false}]},"dependencyGraph":[{"name":"connectivity_plus","dependencies":[]},{"name":"flutter_plugin_android_lifecycle","dependencies":[]},{"name":"flutter_secure_storage","dependencies":["flutter_secure_storage_linux","flutter_secure_storage_macos","flutter_secure_storage_web","flutter_secure_storage_windows"]},{"name":"flutter_secure_storage_linux","dependencies":[]},{"name":"flutter_secure_storage_macos","dependencies":[]},{"name":"flutter_secure_storage_web","dependencies":[]},{"name":"flutter_secure_storage_windows","dependencies":["path_provider"]},{"name":"geolocator","dependencies":["geolocator_android","geolocator_apple","geolocator_web","geolocator_windows"]},{"name":"geolocator_android","dependencies":[]},{"name":"geolocator_apple","dependencies":[]},{"name":"geolocator_web","dependencies":[]},{"name":"geolocator_windows","dependencies":[]},{"name":"google_maps_flutter","dependencies":["google_maps_flutter_android","google_maps_flutter_ios","google_maps_flutter_web"]},{"name":"google_maps_flutter_android","dependencies":["flutter_plugin_android_lifecycle"]},{"name":"google_maps_flutter_ios","dependencies":[]},{"name":"google_maps_flutter_web","dependencies":[]},{"name":"integration_test","dependencies":[]},{"name":"jni","dependencies":[]},{"name":"jni_flutter","dependencies":["jni"]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":["jni","jni_flutter"]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]},{"name":"sqflite_sqlcipher","dependencies":[]}],"date_created":"2026-09-12 17:43:15.622135","version":"3.44.8","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/apps/acs/android/app/build.gradle.kts b/apps/acs/android/app/build.gradle.kts index 6689f48..72506f7 100644 --- a/apps/acs/android/app/build.gradle.kts +++ b/apps/acs/android/app/build.gradle.kts @@ -1,3 +1,14 @@ +import java.util.Base64 + +fun dartDefineValue(name: String): String? { + val encoded = project.findProperty("dart-defines")?.toString() ?: return null + return encoded.split(",").firstNotNullOfOrNull { item -> + runCatching { + String(Base64.getDecoder().decode(item), Charsets.UTF_8) + }.getOrNull()?.takeIf { it.startsWith("$name=") }?.substringAfter('=') + } +} + plugins { id("com.android.application") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. @@ -5,7 +16,7 @@ plugins { } android { - namespace = "com.example.sinalacs_acs" + namespace = "br.com.prismrr.sinalacs.acs" compileSdk = 36 ndkVersion = flutter.ndkVersion @@ -16,13 +27,14 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.example.sinalacs_acs" + applicationId = "br.com.prismrr.sinalacs.acs" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = 24 targetSdk = 36 versionCode = flutter.versionCode versionName = flutter.versionName + manifestPlaceholders["GOOGLE_MAPS_API_KEY"] = dartDefineValue("GOOGLE_MAPS_API_KEY") ?: "" } buildTypes { @@ -43,3 +55,63 @@ kotlin { flutter { source = "../.." } + +// Guarda contra o APK silenciosamente inútil: SINALACS_MQTT_PASSWORD é +// constante de compilação sem default (ver backend_config.dart) porque o +// broker usa um segredo por máquina — nenhum valor embutido no código +// poderia acertá-lo. Sem esta guarda, `flutter build apk` puro compila com +// sucesso um APK que nunca recebe alerta nenhum, e isso só se denuncia em +// tempo de execução, pelo banner "compilado sem a senha". +// +// Acoplada a dois detalhes internos do Flutter Gradle Plugin: a propriedade +// "dart-defines" (uma lista de pares CHAVE=VALOR em base64, separados por +// vírgula — FlutterPlugin.kt lê a mesma propriedade) e o prefixo de nome +// "compileFlutterBuild" das tarefas de compilação Dart. Se um upgrade do +// Flutter renomear qualquer um dos dois, esta guarda falha ABERTA — para de +// bloquear, sem avisar. `flutter build apk` sem senha voltando a passar é o +// sintoma; conferir isto faz parte de todo upgrade do Flutter. +fun hasMqttPassword(): Boolean { + val encoded = project.findProperty("dart-defines")?.toString() ?: return false + return encoded.split(",").any { item -> + runCatching { + String(Base64.getDecoder().decode(item), Charsets.UTF_8) + }.getOrNull()?.let { + it.startsWith("SINALACS_MQTT_PASSWORD=") && it.substringAfter('=').isNotEmpty() + } ?: false + } +} + +// doFirst de TAREFA, nunca bloco de configuração: na configuração, isto +// dispararia em todo `gradlew`, inclusive o sync do Android Studio (que não +// passa define nenhum) — tornando o projeto impossível de abrir na IDE. No +// doFirst só roda quando o Dart vai de fato ser compilado. +tasks.configureEach { + if (!name.startsWith("compileFlutterBuild")) return@configureEach + doFirst { + val allowMissing = project.hasProperty("sinalacs.allowMissingMqttPassword") + if (hasMqttPassword()) return@doFirst + if (allowMissing) { + logger.warn("aviso: compilando sem SINALACS_MQTT_PASSWORD — este APK não vai receber alerta nenhum.") + return@doFirst + } + // Nunca imprimir "dart-defines" aqui: a lista carrega a própria senha + // quando ela FOI passada com outro nome de chave por engano. + throw GradleException( + """ + |Este APK não receberia alerta nenhum: falta --dart-define=SINALACS_MQTT_PASSWORD. + | + |O broker cria o usuário acs-area-12 com MQTT_ACS_PASSWORD, um segredo por + |máquina gerado por scripts/dev/bootstrap_env.sh — nenhum valor embutido no + |código poderia acertá-lo. + | + | ./scripts/dev/run_acs.sh # flutter run + | ./scripts/dev/run_acs.sh --build # APK de depuração + | ./scripts/qa/e2e.sh --emulator # integration_test + | + |Para compilar de propósito sem a senha — por exemplo, para reproduzir na tela + |o aviso "compilado sem a senha" —, acrescente: + | -Psinalacs.allowMissingMqttPassword=true + """.trimMargin() + ) + } +} diff --git a/apps/acs/android/app/src/debug/AndroidManifest.xml b/apps/acs/android/app/src/debug/AndroidManifest.xml index 399f698..cfc90aa 100644 --- a/apps/acs/android/app/src/debug/AndroidManifest.xml +++ b/apps/acs/android/app/src/debug/AndroidManifest.xml @@ -4,4 +4,9 @@ to allow setting breakpoints, to provide hot reload, etc. --> + + + diff --git a/apps/acs/android/app/src/debug/res/xml/network_security_config.xml b/apps/acs/android/app/src/debug/res/xml/network_security_config.xml new file mode 100644 index 0000000..0e78459 --- /dev/null +++ b/apps/acs/android/app/src/debug/res/xml/network_security_config.xml @@ -0,0 +1,19 @@ + + + + + + 10.0.2.2 + + localhost + 127.0.0.1 + + diff --git a/apps/acs/android/app/src/main/AndroidManifest.xml b/apps/acs/android/app/src/main/AndroidManifest.xml index 922c1a1..84c2c3a 100644 --- a/apps/acs/android/app/src/main/AndroidManifest.xml +++ b/apps/acs/android/app/src/main/AndroidManifest.xml @@ -30,6 +30,9 @@ + + + + + + diff --git a/apps/admin/android/app/src/debug/res/xml/network_security_config.xml b/apps/admin/android/app/src/debug/res/xml/network_security_config.xml new file mode 100644 index 0000000..0e78459 --- /dev/null +++ b/apps/admin/android/app/src/debug/res/xml/network_security_config.xml @@ -0,0 +1,19 @@ + + + + + + 10.0.2.2 + + localhost + 127.0.0.1 + + diff --git a/apps/admin/android/app/src/main/AndroidManifest.xml b/apps/admin/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..971e090 --- /dev/null +++ b/apps/admin/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/admin/android/app/src/main/kotlin/br/com/prismrr/sinalacs/admin/MainActivity.kt b/apps/admin/android/app/src/main/kotlin/br/com/prismrr/sinalacs/admin/MainActivity.kt new file mode 100644 index 0000000..794a286 --- /dev/null +++ b/apps/admin/android/app/src/main/kotlin/br/com/prismrr/sinalacs/admin/MainActivity.kt @@ -0,0 +1,5 @@ +package br.com.prismrr.sinalacs.admin + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/apps/admin/android/app/src/main/res/drawable-v21/launch_background.xml b/apps/admin/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/apps/admin/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/apps/admin/android/app/src/main/res/drawable/launch_background.xml b/apps/admin/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/apps/admin/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/apps/admin/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/apps/admin/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/apps/admin/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/apps/admin/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/apps/admin/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/apps/admin/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/apps/admin/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/apps/admin/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/apps/admin/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/apps/admin/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/apps/admin/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/apps/admin/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/apps/admin/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/apps/admin/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/apps/admin/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/apps/admin/android/app/src/main/res/values-night/styles.xml b/apps/admin/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/apps/admin/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/apps/admin/android/app/src/main/res/values/styles.xml b/apps/admin/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/apps/admin/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/apps/admin/android/app/src/profile/AndroidManifest.xml b/apps/admin/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/apps/admin/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/apps/admin/android/build.gradle.kts b/apps/admin/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/apps/admin/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/apps/admin/android/gradle.properties b/apps/admin/android/gradle.properties new file mode 100644 index 0000000..e96108c --- /dev/null +++ b/apps/admin/android/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false diff --git a/apps/admin/android/gradle/wrapper/gradle-wrapper.properties b/apps/admin/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2d428bf --- /dev/null +++ b/apps/admin/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip diff --git a/apps/admin/android/settings.gradle.kts b/apps/admin/android/settings.gradle.kts new file mode 100644 index 0000000..c21f0c5 --- /dev/null +++ b/apps/admin/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.0.1" apply false + id("org.jetbrains.kotlin.android") version "2.3.20" apply false +} + +include(":app") diff --git a/apps/admin/integration_test/admin_mobile_smoke_test.dart b/apps/admin/integration_test/admin_mobile_smoke_test.dart new file mode 100644 index 0000000..7bbc72c --- /dev/null +++ b/apps/admin/integration_test/admin_mobile_smoke_test.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:sinalacs_admin/app/app.dart'; +import 'package:sinalacs_admin/core/data/mock_admin_data_source.dart'; + +import '../test/support/failing_admin_data_source.dart'; + +/// Validação do backoffice num dispositivo Android real (ou emulador). +/// +/// ATENÇÃO — este arquivo é **hermético**, ao contrário dos `integration_test/` +/// do app ACS e do app do paciente. O backoffice ainda roda sobre +/// `MockAdminDataSource`, então aqui **não** é preciso `docker compose up`, +/// nem seed, nem `--dart-define`. Basta: +/// +/// flutter test integration_test -d emulator-5554 +/// +/// O que isto cobre e `flutter test` não consegue cobrir: o runtime real do +/// Android — densidade de tela, insets do sistema, rotação de verdade e a +/// escala de fonte do aparelho — em vez da janela sintética de 800x600 do +/// `flutter_test`. +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + Future entrar(WidgetTester tester, {dynamic dataSource}) async { + await tester.pumpWidget(SinalAdminApp(dataSource: dataSource, devLoginEnabled: true)); + await tester.pumpAndSettle(); + final entrar = find.byKey(const Key('login_button')); + await tester.ensureVisible(entrar); + await tester.pumpAndSettle(); + await tester.tap(entrar); + await tester.pumpAndSettle(); + } + + Future irPara(WidgetTester tester, String destino) async { + final alvo = find.text(destino).last; + await tester.ensureVisible(alvo); + await tester.pumpAndSettle(); + await tester.tap(alvo); + await tester.pumpAndSettle(); + } + + testWidgets('abre o backoffice no dispositivo e navega pelos quatro destinos', (tester) async { + await entrar(tester); + + expect(find.text('Painel de Indicadores'), findsOneWidget); + await irPara(tester, 'Microáreas'); + expect(find.text('Microáreas e vínculo ACS'), findsOneWidget); + await irPara(tester, 'Alertas'); + expect(find.text('Alertas da UBS'), findsOneWidget); + await irPara(tester, 'Auditoria'); + expect(find.text('Logs de auditoria'), findsOneWidget); + }); + + testWidgets('escolhe a navegação conforme a largura real do aparelho', (tester) async { + await entrar(tester); + + // Em celular sai a barra inferior; em tablet, o rail lateral. Qual dos dois + // depende do aparelho — o que se afirma aqui é que existe exatamente um. + final rail = find.byKey(const Key('admin_navigation_rail')).evaluate().length; + final barra = find.byKey(const Key('admin_navigation_bar')).evaluate().length; + expect(rail + barra, 1, reason: 'deve haver exatamente uma navegação, nunca as duas nem nenhuma'); + }); + + testWidgets('não estoura o layout ao girar o aparelho', (tester) async { + await entrar(tester); + + final tamanhoOriginal = tester.view.physicalSize; + addTearDown(tester.view.reset); + + tester.view.physicalSize = Size(tamanhoOriginal.height, tamanhoOriginal.width); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull, reason: 'estouro de layout após girar para paisagem'); + + tester.view.physicalSize = tamanhoOriginal; + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull, reason: 'estouro de layout ao voltar para retrato'); + }); + + testWidgets('bloqueia a exibição de dado sensível quando a auditoria falha', (tester) async { + // PRD §4.2.2: acesso do administrador precisa ser auditado. Se o registro + // falha, a tela mostra erro — nunca o dado sem auditoria (nada de + // fail-open). Já coberto em `flutter test`; repetido aqui para valer também + // no runtime real do Android. + final dataSource = FailingAdminDataSource(inner: MockAdminDataSource())..failNextRecordAccess = true; + await entrar(tester, dataSource: dataSource); + + await irPara(tester, 'Microáreas'); + + expect(find.text('Microáreas e vínculo ACS'), findsNothing); + expect(find.textContaining('Não foi possível'), findsOneWidget); + }); +} diff --git a/apps/admin/lib/app/admin_layout.dart b/apps/admin/lib/app/admin_layout.dart new file mode 100644 index 0000000..4fcd2fb --- /dev/null +++ b/apps/admin/lib/app/admin_layout.dart @@ -0,0 +1,38 @@ +import 'package:flutter/widgets.dart'; + +/// Pontos de quebra do backoffice. +/// +/// O backoffice é desktop-first (`spec/PRD_system.md` §2.1): o layout com +/// NavigationRail continua sendo o padrão e o layout compacto é complemento +/// para celular, nunca substituição. +/// +/// Existem como constantes nomeadas porque o mesmo número é consultado em +/// pontos distantes do `app.dart` (shell, cabeçalho, filtros, cartões, +/// listas). Enquanto era o literal `640` solto em um único `LayoutBuilder`, +/// qualquer segundo uso teria sido uma cópia sem relação declarada com a +/// primeira. +abstract final class AdminBreakpoints { + /// Acima disto, NavigationRail lateral; abaixo, NavigationBar inferior. + static const double rail = 640; + + /// Abaixo disto, pares de controles lado a lado passam a empilhar: os dois + /// filtros de Alertas, o rótulo/valor de `_InfoRow` e o `trailing` das + /// listas. 480 e não 600 porque só afeta pares — a 480dp dois campos ainda + /// têm ~230dp cada, que é onde o rótulo do dropdown ainda cabe. + static const double stacked = 480; + + /// Largura-alvo mínima de um cartão de contador antes de reduzir a grade. + static const double counterCardMin = 160; +} + +/// Altura do cabeçalho, acompanhando a escala de fonte do sistema. +/// +/// `PreferredSizeWidget.preferredSize` é um getter sem `BuildContext`, então a +/// altura precisa ser calculada por quem monta o `Scaffold` e passada adiante. +/// Sem isso, com fonte grande no Android as duas linhas do título estouram os +/// 72dp fixos — e `AppBar` corta em vez de crescer. +/// +/// O teto de 132 existe para que fonte a 200% não coma metade da tela de um +/// celular; o título já usa elipse, então o corte é o do texto, não do layout. +double adminHeaderHeight(BuildContext context) => + MediaQuery.textScalerOf(context).scale(72).clamp(72.0, 132.0); diff --git a/apps/admin/lib/app/admin_theme.dart b/apps/admin/lib/app/admin_theme.dart new file mode 100644 index 0000000..7075197 --- /dev/null +++ b/apps/admin/lib/app/admin_theme.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; + +abstract final class AdminColors { + static const background = Color(0xFF030712); + static const surface = Color(0xFF111827); + static const surfaceRaised = Color(0xFF1F2937); + static const border = Color(0xFF374151); + static const accent = Color(0xFF4F46E5); + static const red = Color(0xFFDC2626); + static const yellow = Color(0xFFF59E0B); + static const green = Color(0xFF10B981); + + /// Variantes de `red`/`accent` para uso como TEXTO/ÍCONE sobre superfície + /// escura (`surface`/`surfaceRaised`), não como preenchimento. + /// + /// `red` cai para 3,04:1 sobre `surfaceRaised` e `accent` para 2,33:1, contra + /// os 4,5:1 da WCAG 1.4.3. Como preenchimento os dois continuam corretos + /// (branco sobre `red` dá 4,83:1; sobre `accent`, 6,29:1), por isso não são + /// substituídos — só a leitura como texto muda. `yellow` e `green` não + /// precisam de variante: já passam (6,83:1 e 5,79:1) sobre `surfaceRaised`. + /// + /// `accentOnSurface` é indigo-400, e não o `#60A5FA` do ACS: o accent do + /// backoffice é indigo `#4F46E5`, não o azul `#2563EB` do ACS. A regra + /// compartilhada é o passo -400 da mesma cor, não o valor literal. + static const redOnSurface = Color(0xFFF87171); + static const accentOnSurface = Color(0xFF818CF8); +} + +/// Cor de texto/ícone equivalente a uma cor clínica de preenchimento. +/// +/// Ponto único de conversão: `riskColor()` em `app.dart` alimenta tanto um +/// `backgroundColor`/borda (fill) quanto, em alguns pontos, um `TextStyle` +/// direto — sem este helper cada call site teria que lembrar sozinho de +/// trocar `red`/`accent` pela variante. `yellow`/`green` retornam inalterados. +Color adminOnSurface(Color fill) => switch (fill) { + AdminColors.red => AdminColors.redOnSurface, + AdminColors.accent => AdminColors.accentOnSurface, + _ => fill, + }; + +ThemeData buildAdminTheme() => ThemeData( + useMaterial3: true, + brightness: Brightness.dark, + scaffoldBackgroundColor: AdminColors.background, + colorScheme: ColorScheme.fromSeed( + seedColor: AdminColors.accent, + brightness: Brightness.dark, + surface: AdminColors.surface, + ), + appBarTheme: const AppBarTheme( + backgroundColor: AdminColors.surface, + foregroundColor: Colors.white, + elevation: 0, + ), + navigationRailTheme: const NavigationRailThemeData( + backgroundColor: AdminColors.surface, + ), + cardTheme: CardThemeData( + color: AdminColors.surfaceRaised, + elevation: 0, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AdminColors.border), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: AdminColors.surface, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: AdminColors.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: AdminColors.border), + ), + ), + ); diff --git a/apps/admin/lib/app/app.dart b/apps/admin/lib/app/app.dart new file mode 100644 index 0000000..291bb2d --- /dev/null +++ b/apps/admin/lib/app/app.dart @@ -0,0 +1,842 @@ +import 'package:flutter/foundation.dart' show kDebugMode; +import 'package:flutter/material.dart'; +import 'package:sinalacs_admin/app/admin_layout.dart'; +import 'package:sinalacs_admin/app/admin_theme.dart'; +import 'package:sinalacs_admin/core/data/admin_data_source.dart'; +import 'package:sinalacs_admin/core/data/mock_admin_data_source.dart'; + +class SinalAdminApp extends StatelessWidget { + SinalAdminApp({super.key, AdminDataSource? dataSource, this.devLoginEnabled}) : dataSource = dataSource ?? MockAdminDataSource(); + + final AdminDataSource dataSource; + + /// Repassado para [LoginScreen]; `null` mantém o default (`kDebugMode`). + final bool? devLoginEnabled; + + @override + Widget build(BuildContext context) => MaterialApp( + title: 'SinalACS Admin', + debugShowCheckedModeBanner: false, + theme: buildAdminTheme(), + home: LoginScreen(dataSource: dataSource, devLoginEnabled: devLoginEnabled), + ); +} + +/// Login local (não chama `auth.developmentLogin`). +/// +/// Investigado antes de decidir: `backend/sinalacs_server/lib/src/endpoints/auth_endpoint.dart` +/// só aceita `role: 'patient'` ou `role: 'acs'` — não existe usuário fixo de +/// desenvolvimento para `admin`, então a chamada real falharia com +/// AlertValidationException. Ligar isso de verdade exige uma mudança no +/// backend (fora do escopo desta issue); ver descrição do PR. +class LoginScreen extends StatefulWidget { + /// O banner "ambiente de desenvolvimento" não é um controle de acesso — só + /// avisa. Sem isso, `_login` deixaria qualquer pessoa entrar em produção + /// sem senha (achado da revisão do PR). O default (`kDebugMode`, `false` + /// em builds profile/release) desativa de verdade o bypass fora de dev; + /// o parâmetro existe para os testes poderem exercitar os dois estados. + const LoginScreen({required this.dataSource, super.key, bool? devLoginEnabled}) : devLoginEnabled = devLoginEnabled ?? kDebugMode; + + final AdminDataSource dataSource; + final bool devLoginEnabled; + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final _matricula = TextEditingController(); + final _senha = TextEditingController(); + + @override + void dispose() { + _matricula.dispose(); + _senha.dispose(); + super.dispose(); + } + + void _login() { + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => AdminHomeShell(dataSource: widget.dataSource)), + ); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: _Header('Backoffice SinalACS', 'Acesso administrativo', height: adminHeaderHeight(context)), + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: ListView( + padding: const EdgeInsets.all(24), + children: [ + Container( + key: const Key('dev_banner'), + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: AdminColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AdminColors.accent), + ), + child: const Row( + children: [ + Icon(Icons.science_outlined, color: AdminColors.accentOnSurface), + SizedBox(width: 12), + Expanded( + child: Text( + 'Ambiente de desenvolvimento — sem autenticação institucional real (SSO/gov.br).', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ], + ), + ), + Card( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + const CircleAvatar(radius: 32, child: Text('ADM')), + const SizedBox(height: 16), + const Text('SinalACS', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + const Text('Backoffice administrativo', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 20), + TextField( + key: const Key('matricula_field'), + controller: _matricula, + decoration: const InputDecoration(labelText: 'Matrícula / CNS'), + ), + const SizedBox(height: 16), + TextField( + key: const Key('senha_field'), + controller: _senha, + obscureText: true, + decoration: const InputDecoration(labelText: 'Senha de acesso'), + ), + const SizedBox(height: 20), + Semantics( + label: 'Entrar no backoffice administrativo', + button: true, + container: true, + child: SizedBox( + width: double.infinity, + child: FilledButton( + key: const Key('login_button'), + style: FilledButton.styleFrom(minimumSize: const Size(48, 52)), + onPressed: widget.devLoginEnabled ? _login : null, + child: const Text('Entrar'), + ), + ), + ), + if (!widget.devLoginEnabled) ...[ + const SizedBox(height: 12), + const Text( + 'Login de desenvolvimento desativado nesta build (fora do modo debug).', + key: Key('dev_login_disabled_notice'), + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white70, fontSize: 12), + ), + ], + ], + ), + ), + ), + ], + ), + ), + ), + ); +} + +enum AdminDestination { indicators, microAreas, alerts, auditLog } + +extension on AdminDestination { + String get label => switch (this) { + AdminDestination.indicators => 'Indicadores', + AdminDestination.microAreas => 'Microáreas', + AdminDestination.alerts => 'Alertas', + AdminDestination.auditLog => 'Auditoria', + }; + + IconData get icon => switch (this) { + AdminDestination.indicators => Icons.dashboard_outlined, + AdminDestination.microAreas => Icons.map_outlined, + AdminDestination.alerts => Icons.warning_amber_outlined, + AdminDestination.auditLog => Icons.fact_check_outlined, + }; +} + +class AdminHomeShell extends StatefulWidget { + const AdminHomeShell({required this.dataSource, super.key}); + + final AdminDataSource dataSource; + + @override + State createState() => _AdminHomeShellState(); +} + +class _AdminHomeShellState extends State { + AdminDestination destination = AdminDestination.indicators; + + Widget _content() => switch (destination) { + AdminDestination.indicators => IndicatorsScreen(dataSource: widget.dataSource), + AdminDestination.microAreas => MicroAreasScreen(dataSource: widget.dataSource), + AdminDestination.alerts => AlertsScreen(dataSource: widget.dataSource), + AdminDestination.auditLog => AuditLogScreen(dataSource: widget.dataSource), + }; + + void _select(int index) => setState(() => destination = AdminDestination.values[index]); + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) { + final content = _content(); + final header = _Header('Backoffice • admin.dev', 'Painel administrativo', height: adminHeaderHeight(context)); + // Backoffice é desktop-first (spec/PRD_system.md §2.1): NavigationRail + // acima de AdminBreakpoints.rail, NavigationBar abaixo — mesmo + // ThemeData nos dois. + if (constraints.maxWidth >= AdminBreakpoints.rail) { + return Scaffold( + appBar: header, + // SafeArea envolve a linha inteira, e não só o conteúdo: num + // celular em paisagem é o rail que encosta no recorte da câmera. + // `top: false` porque a AppBar já trata o inset de cima. + body: SafeArea( + top: false, + child: Row( + children: [ + NavigationRail( + key: const Key('admin_navigation_rail'), + selectedIndex: destination.index, + onDestinationSelected: _select, + labelType: NavigationRailLabelType.all, + // Em paisagem de celular sobram ~288dp de altura para + // quatro destinos rotulados: cabe por poucos pixels com + // fonte padrão e estoura com fonte ampliada. `scrollable` + // troca o estouro por rolagem em vez de cortar destino. + scrollable: true, + destinations: [ + for (final value in AdminDestination.values) + NavigationRailDestination(icon: Icon(value.icon), label: Text(value.label)), + ], + ), + const VerticalDivider(width: 1), + Expanded(child: content), + ], + ), + ), + ); + } + return Scaffold( + appBar: header, + // `bottom: false`: o NavigationBar do Scaffold já trata o inset + // inferior; duplicar aqui abriria uma faixa morta acima dele. + body: SafeArea(top: false, bottom: false, child: content), + bottomNavigationBar: NavigationBar( + key: const Key('admin_navigation_bar'), + selectedIndex: destination.index, + onDestinationSelected: _select, + destinations: [ + for (final value in AdminDestination.values) + NavigationDestination(icon: Icon(value.icon), label: value.label), + ], + ), + ); + }, + ); +} + +String riskLabel(RiskLevel level) => switch (level) { + RiskLevel.red => 'Vermelho', + RiskLevel.yellow => 'Amarelo', + RiskLevel.green => 'Verde', + }; + +Color riskColor(RiskLevel level) => switch (level) { + RiskLevel.red => AdminColors.red, + RiskLevel.yellow => AdminColors.yellow, + RiskLevel.green => AdminColors.green, + }; + +/// Timestamp de auditoria em `dd/MM/aaaa HH:mm`, hora local. +/// +/// A tela imprimia `DateTime.toString()` cru — `2026-09-15 08:32:11.000Z` — +/// que é largo e cheio de ruído (milissegundos, sufixo Z) sem valor nenhum +/// para quem audita. Sem `intl` de propósito: o backoffice não tem nenhuma +/// dependência externa hoje e um formato pt-BR fixo basta; internacionalização +/// não está no escopo. +String formatAuditTimestamp(DateTime timestamp) { + final local = timestamp.toLocal(); + String dois(int valor) => valor.toString().padLeft(2, '0'); + return '${dois(local.day)}/${dois(local.month)}/${local.year} ${dois(local.hour)}:${dois(local.minute)}'; +} + +String statusLabel(AlertStatus status) => switch (status) { + AlertStatus.pending => 'Pendente', + AlertStatus.acknowledged => 'Reconhecido', + AlertStatus.resolved => 'Resolvido', + AlertStatus.escalated => 'Escalonado', + }; + +/// Estado de erro compartilhado pelas telas assíncronas, com retry. +/// +/// Sem isso, um `FutureBuilder` que falha fica com `hasData == false` para +/// sempre (spinner infinito) ou, pior, cai no mesmo ramo de "vazio" que os +/// dados realmente vazios — escondendo uma falha de rede/backend como se +/// não houvesse nada para mostrar (achado da revisão do Copilot no PR). +class _AsyncError extends StatelessWidget { + const _AsyncError({required this.message, required this.onRetry}); + + final String message; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) => Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.cloud_off_outlined, size: 32, color: Colors.white70), + const SizedBox(height: 12), + Text(message, textAlign: TextAlign.center), + const SizedBox(height: 16), + OutlinedButton.icon(onPressed: onRetry, icon: const Icon(Icons.refresh), label: const Text('Tentar novamente')), + ], + ), + ), + ); +} + +class IndicatorsScreen extends StatefulWidget { + const IndicatorsScreen({required this.dataSource, super.key}); + + final AdminDataSource dataSource; + + @override + State createState() => _IndicatorsScreenState(); +} + +class _IndicatorsScreenState extends State { + late Future _future = widget.dataSource.fetchDashboardIndicators(); + + void _retry() => setState(() { + _future = widget.dataSource.fetchDashboardIndicators(); + }); + + @override + Widget build(BuildContext context) => FutureBuilder( + future: _future, + builder: (context, snapshot) { + if (snapshot.hasError) { + return _AsyncError(message: 'Não foi possível carregar os indicadores.', onRetry: _retry); + } + if (!snapshot.hasData) return const Center(child: CircularProgressIndicator()); + final data = snapshot.data!; + return _page([ + const Text('Painel de Indicadores', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + const Text('Contadores por risco clínico da UBS'), + const SizedBox(height: 16), + LayoutBuilder( + builder: (context, constraints) { + // Os cartões tinham 160dp fixos dentro de um Wrap: cabiam, mas + // deixavam um vão à direita no desktop e não se adaptavam a + // nada. A grade calculada distribui a largura disponível e cai + // para uma coluna quando não há espaço para duas. + const espaco = 12.0; + final colunas = ((constraints.maxWidth + espaco) / (AdminBreakpoints.counterCardMin + espaco)) + .floor() + .clamp(1, RiskLevel.values.length); + final largura = (constraints.maxWidth - espaco * (colunas - 1)) / colunas; + return Wrap( + spacing: espaco, + runSpacing: espaco, + children: [ + for (final level in RiskLevel.values) + _CounterCard( + key: Key('risk_counter_${level.name}'), + label: riskLabel(level), + value: '${data.countsByRisk[level] ?? 0}', + color: riskColor(level), + width: largura, + ), + ], + ); + }, + ), + const SizedBox(height: 20), + const Divider(), + const SizedBox(height: 12), + const Text('Alertas vermelhos', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + _InfoRow('Abertos (pendentes)', '${data.openRedAlerts}'), + _InfoRow('Reconhecidos', '${data.acknowledgedRedAlerts}'), + _InfoRow('TMRAV (tempo médio de resposta)', '${data.tmravSeconds}s'), + ]); + }, + ); +} + +class _CounterCard extends StatelessWidget { + const _CounterCard({required this.label, required this.value, required this.color, required this.width, super.key}); + + final String label; + final String value; + final Color color; + final double width; + + @override + Widget build(BuildContext context) => Container( + width: width, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AdminColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border(left: BorderSide(color: color, width: 4)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(value, style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + Text(label, style: TextStyle(color: adminOnSurface(color), fontWeight: FontWeight.bold)), + ], + ), + ); +} + +class MicroAreasScreen extends StatefulWidget { + const MicroAreasScreen({required this.dataSource, super.key}); + + final AdminDataSource dataSource; + + @override + State createState() => _MicroAreasScreenState(); +} + +class _MicroAreasScreenState extends State { + late Future> _future = _load(); + + /// Registra o acesso *antes* de expor os dados — se o registro falhar, a + /// tela cai no estado de erro em vez de mostrar dado sensível sem auditoria + /// (PRD §4.2.2: acesso do Administrador precisa ser auditado). + Future> _load() async { + await widget.dataSource.recordAccess(actionType: 'view', resourceType: 'micro_areas'); + return widget.dataSource.fetchMicroAreas(); + } + + void _retry() => setState(() { + _future = _load(); + }); + + @override + Widget build(BuildContext context) => FutureBuilder>( + future: _future, + builder: (context, snapshot) { + if (snapshot.hasError) { + return _AsyncError(message: 'Não foi possível carregar as microáreas.', onRetry: _retry); + } + if (!snapshot.hasData) return const Center(child: CircularProgressIndicator()); + final areas = snapshot.data!; + return ListView( + padding: const EdgeInsets.all(20), + children: [ + const Text('Microáreas e vínculo ACS', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + const Text('Listagem somente leitura — edição de vínculo fica para uma próxima issue.'), + const SizedBox(height: 16), + for (final area in areas) + Card( + key: Key('micro_area_${area.id}'), + margin: const EdgeInsets.only(bottom: 12), + child: _LinhaComSelo( + titulo: Text(area.name), + descricao: Text('ACS: ${area.acsName} (${area.acsEnrollmentId})'), + selo: Chip( + avatar: Icon(area.acsActive ? Icons.check_circle_outline : Icons.remove_circle_outline, size: 18), + label: Text(area.acsActive ? 'Ativo' : 'Sem ACS ativo'), + backgroundColor: AdminColors.surface, + ), + ), + ), + ], + ); + }, + ); +} + +class AlertsScreen extends StatefulWidget { + const AlertsScreen({required this.dataSource, super.key}); + + final AdminDataSource dataSource; + + @override + State createState() => _AlertsScreenState(); +} + +class _AlertsScreenState extends State { + String? _microAreaFilter; + AlertStatus? _statusFilter; + late Future _accessRecorded = widget.dataSource.recordAccess(actionType: 'view', resourceType: 'alerts'); + + @override + Widget build(BuildContext context) => FutureBuilder( + future: _accessRecorded, + builder: (context, accessSnapshot) { + if (accessSnapshot.hasError) { + return _AsyncError( + message: 'Não foi possível registrar o acesso a esta tela.', + onRetry: () => setState(() { + _accessRecorded = widget.dataSource.recordAccess(actionType: 'view', resourceType: 'alerts'); + }), + ); + } + if (accessSnapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + return _AlertsList(dataSource: widget.dataSource, microAreaFilter: _microAreaFilter, statusFilter: _statusFilter, onFilterChanged: (microArea, status) => setState(() { + _microAreaFilter = microArea; + _statusFilter = status; + })); + }, + ); +} + +class _AlertsList extends StatelessWidget { + const _AlertsList({ + required this.dataSource, + required this.microAreaFilter, + required this.statusFilter, + required this.onFilterChanged, + }); + + final AdminDataSource dataSource; + final String? microAreaFilter; + final AlertStatus? statusFilter; + final void Function(String? microArea, AlertStatus? status) onFilterChanged; + + /// Busca alertas e microáreas juntos. As opções do filtro vêm daqui, não de + /// uma lista fixa no código — senão uma microárea nova (ou uma fonte real, + /// no lugar do mock) devolveria alertas para um valor que não existe mais + /// como opção selecionável (achado da revisão do PR). + Future<({List alerts, List microAreas})> _load() async { + final alerts = await dataSource.fetchAlerts(microAreaName: microAreaFilter, status: statusFilter); + final microAreas = await dataSource.fetchMicroAreas(); + return (alerts: alerts, microAreas: microAreas); + } + + @override + Widget build(BuildContext context) => FutureBuilder( + future: _load(), + builder: (context, snapshot) { + if (snapshot.hasError) { + return _AsyncError(message: 'Não foi possível carregar os alertas.', onRetry: () => onFilterChanged(microAreaFilter, statusFilter)); + } + final alerts = snapshot.data?.alerts ?? const []; + final microAreas = snapshot.data?.microAreas ?? const []; + return ListView( + padding: const EdgeInsets.all(20), + children: [ + const Text('Alertas da UBS', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + const Text('Consulta somente leitura — nenhuma reclassificação de risco é permitida aqui.'), + const SizedBox(height: 16), + _FiltrosDeAlertas( + microArea: DropdownButtonFormField( + key: const Key('alerts_micro_area_filter'), + initialValue: microAreaFilter, + isExpanded: true, + // DropdownButton exibe o hint (não o child do item) quando o + // valor selecionado é null — sem isso, "Todas" some do campo + // fechado assim que selecionado (achado da revisão do PR). + hint: const Text('Todas'), + decoration: const InputDecoration(labelText: 'Microárea'), + items: [ + const DropdownMenuItem(value: null, child: Text('Todas')), + for (final area in microAreas) + DropdownMenuItem(value: area.name, child: Text(area.name, overflow: TextOverflow.ellipsis)), + ], + onChanged: (value) => onFilterChanged(value, statusFilter), + ), + status: DropdownButtonFormField( + key: const Key('alerts_status_filter'), + initialValue: statusFilter, + isExpanded: true, + hint: const Text('Todos'), + decoration: const InputDecoration(labelText: 'Status'), + items: [ + const DropdownMenuItem(value: null, child: Text('Todos')), + for (final status in AlertStatus.values) + DropdownMenuItem(value: status, child: Text(statusLabel(status), overflow: TextOverflow.ellipsis)), + ], + onChanged: (value) => onFilterChanged(microAreaFilter, value), + ), + ), + const SizedBox(height: 16), + if (snapshot.connectionState == ConnectionState.waiting) + const Center(child: CircularProgressIndicator()) + else if (alerts.isEmpty) + const Padding(padding: EdgeInsets.only(top: 24), child: Text('Nenhum alerta para o filtro selecionado.')) + else + for (final alert in alerts) + Card( + key: Key('alert_${alert.id}'), + margin: const EdgeInsets.only(bottom: 12), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(border: Border(left: BorderSide(color: riskColor(alert.riskLevel), width: 4))), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(alert.patientLabel, style: const TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + Text(alert.microAreaName), + const SizedBox(height: 6), + Text('Risco: ${riskLabel(alert.riskLevel)}', style: TextStyle(color: adminOnSurface(riskColor(alert.riskLevel)), fontWeight: FontWeight.bold)), + Text('Status: ${statusLabel(alert.status)}'), + ], + ), + ), + ), + ], + ); + }, + ); +} + +/// Os dois filtros de Alertas, lado a lado ou empilhados. +/// +/// Lado a lado num celular cada campo recebia ~170dp: `isExpanded` e a elipse +/// dos itens evitavam o estouro, mas o rótulo do campo e o valor selecionado +/// passavam a disputar a mesma linha e "Microárea 12 — Zona Rural" virava +/// reticências. Não era um bug de layout — era um campo ilegível. +class _FiltrosDeAlertas extends StatelessWidget { + const _FiltrosDeAlertas({required this.microArea, required this.status}); + + final Widget microArea; + final Widget status; + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < AdminBreakpoints.stacked) { + return Column(children: [microArea, const SizedBox(height: 12), status]); + } + return Row( + children: [ + Expanded(child: microArea), + const SizedBox(width: 12), + Expanded(child: status), + ], + ); + }, + ); +} + +class AuditLogScreen extends StatefulWidget { + const AuditLogScreen({required this.dataSource, super.key}); + + final AdminDataSource dataSource; + + @override + State createState() => _AuditLogScreenState(); +} + +class _AuditLogScreenState extends State { + late Future _selfAuditRecorded = widget.dataSource.recordAccess(actionType: 'view', resourceType: 'audit_logs'); + + @override + Widget build(BuildContext context) => FutureBuilder( + future: _selfAuditRecorded, + builder: (context, recordSnapshot) { + if (recordSnapshot.hasError) { + return _AsyncError( + message: 'Não foi possível registrar o acesso a esta tela.', + onRetry: () => setState(() { + _selfAuditRecorded = widget.dataSource.recordAccess(actionType: 'view', resourceType: 'audit_logs'); + }), + ); + } + if (recordSnapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + return FutureBuilder>( + future: widget.dataSource.fetchAuditLogs(), + builder: (context, snapshot) { + if (snapshot.hasError) { + return _AsyncError(message: 'Não foi possível carregar os logs de auditoria.', onRetry: () => setState(() {})); + } + final entries = snapshot.data ?? const []; + return ListView( + padding: const EdgeInsets.all(20), + children: [ + const Text('Logs de auditoria', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + const Text('Somente leitura. O próprio acesso do administrador a esta tela também é auditado.'), + const SizedBox(height: 16), + if (entries.isEmpty) + const Padding(padding: EdgeInsets.only(top: 24), child: Text('Nenhum acesso registrado ainda.')) + else + for (final entry in entries) + Card( + key: Key('audit_${entry.id}'), + margin: const EdgeInsets.only(bottom: 8), + child: _LinhaComSelo( + icone: const Icon(Icons.verified_user_outlined), + titulo: Text('${entry.actionType} • ${entry.resourceType}'), + descricao: Text('${entry.userLabel} — ${formatAuditTimestamp(entry.timestamp)}'), + selo: Text(entry.result), + ), + ), + ], + ); + }, + ); + }, + ); +} + +Widget _page(List children) => ListView( + padding: const EdgeInsets.all(20), + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: children), + ), + ), + ], + ); + +/// `ListTile` cujo `trailing` desce para baixo da descrição em tela estreita. +/// +/// Um `trailing` largo ("Sem ACS ativo") comia ~140dp dos ~320dp úteis de um +/// celular e espremia o título contra a borda. Abaixo do ponto de quebra o selo +/// vira mais uma linha do conteúdo, em vez de competir por largura. +/// +/// Usado pelas telas de Microáreas e de Auditoria, que tinham exatamente o +/// mesmo formato e o mesmo problema. +class _LinhaComSelo extends StatelessWidget { + const _LinhaComSelo({required this.titulo, required this.descricao, required this.selo, this.icone}); + + final Widget titulo; + final Widget descricao; + final Widget selo; + final Widget? icone; + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) { + final compacto = constraints.maxWidth < AdminBreakpoints.stacked; + return ListTile( + leading: icone, + title: titulo, + isThreeLine: compacto, + subtitle: compacto + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + descricao, + const SizedBox(height: 8), + Align(alignment: Alignment.centerLeft, child: selo), + ], + ) + : descricao, + trailing: compacto ? null : selo, + ); + }, + ); +} + +class _InfoRow extends StatelessWidget { + const _InfoRow(this.label, this.value); + + final String label; + final String value; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: LayoutBuilder( + builder: (context, constraints) { + final valor = Text(value, style: const TextStyle(fontWeight: FontWeight.bold)); + // Rótulos como "TMRAV (tempo médio de resposta)" quebram em três + // linhas ao lado do valor num celular. Empilhados, o rótulo usa a + // largura toda e lê como uma frase. + if (constraints.maxWidth < AdminBreakpoints.stacked) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [Text(label), valor], + ); + } + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: Text(label)), + const SizedBox(width: 12), + Text(value, textAlign: TextAlign.end, style: const TextStyle(fontWeight: FontWeight.bold)), + ], + ); + }, + ), + ); +} + +class _Header extends StatelessWidget implements PreferredSizeWidget { + const _Header(this.eyebrow, this.title, {required this.height}); + + final String eyebrow; + final String title; + + /// Calculada por quem monta o Scaffold, via [adminHeaderHeight] — ver o + /// porquê lá: `preferredSize` não tem acesso ao `BuildContext`. + final double height; + + @override + Size get preferredSize => Size.fromHeight(height); + + @override + Widget build(BuildContext context) { + // O selo é informação, não controle. Em tela estreita ele disputa espaço + // com duas linhas de título num AppBar, então vira ícone — mantendo o + // rótulo para leitores de tela, que é o que de fato carrega o significado. + final estreito = MediaQuery.sizeOf(context).width < AdminBreakpoints.stacked; + return AppBar( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + eyebrow.toUpperCase(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 10, color: AdminColors.accentOnSurface, fontWeight: FontWeight.bold), + ), + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + ], + ), + actions: [ + Padding( + padding: const EdgeInsets.only(right: 12), + child: estreito + ? Tooltip( + message: 'Acesso auditado', + child: Semantics( + label: 'Acesso auditado', + child: const Icon(key: Key('admin_audit_badge'), Icons.verified_user_outlined), + ), + ) + : const Chip(key: Key('admin_audit_badge'), label: Text('Acesso auditado')), + ), + ], + ); + } +} diff --git a/apps/admin/lib/core/data/admin_data_source.dart b/apps/admin/lib/core/data/admin_data_source.dart new file mode 100644 index 0000000..2ba8c85 --- /dev/null +++ b/apps/admin/lib/core/data/admin_data_source.dart @@ -0,0 +1,104 @@ +/// Modelos e contrato de dados do backoffice. +/// +/// Os campos espelham os modelos reais do backend (`backend/sinalacs_server/lib/src/models/*.spy.yaml`) +/// para que trocar [MockAdminDataSource] por uma implementação sobre o `sinalacs_client` +/// não exija remodelar as telas. +library; + +/// Espelha `RiskLevel` de `models/enums/risk_level.spy.yaml`. +enum RiskLevel { red, yellow, green } + +/// Espelha `AlertStatus` de `models/enums/alert_status.spy.yaml`. +enum AlertStatus { pending, acknowledged, resolved, escalated } + +class DashboardIndicators { + const DashboardIndicators({ + required this.countsByRisk, + required this.openRedAlerts, + required this.acknowledgedRedAlerts, + required this.tmravSeconds, + }); + + final Map countsByRisk; + final int openRedAlerts; + final int acknowledgedRedAlerts; + + /// Tempo Médio de Resposta a Alerta Vermelho (métrica North Star do PRD §1.3). + final int tmravSeconds; +} + +/// Vínculo ACS ↔ microárea, para a listagem somente leitura da issue. +/// Espelha `MicroArea` (`micro_area.spy.yaml`) e `Acs` (`acs.spy.yaml`). +class MicroAreaSummary { + const MicroAreaSummary({ + required this.id, + required this.name, + required this.acsName, + required this.acsEnrollmentId, + required this.acsActive, + }); + + final String id; + final String name; + final String acsName; + final String acsEnrollmentId; + final bool acsActive; +} + +/// Espelha `Alert` (`alert.spy.yaml`). `patientLabel` é um identificador +/// minimizado (não o nome do paciente) — o backoffice é o cliente que mais +/// toca dado sensível (spec/lgpd_design.md), então a listagem evita PII +/// desnecessária para o que a issue pede (consulta, não atendimento). +class AlertSummary { + const AlertSummary({ + required this.id, + required this.patientLabel, + required this.microAreaName, + required this.riskLevel, + required this.status, + required this.triggeredAt, + }); + + final String id; + final String patientLabel; + final String microAreaName; + final RiskLevel riskLevel; + final AlertStatus status; + final DateTime triggeredAt; +} + +/// Espelha `AuditLog` (`audit_log.spy.yaml`). +class AuditLogEntry { + const AuditLogEntry({ + required this.id, + required this.userLabel, + required this.actionType, + required this.resourceType, + required this.timestamp, + required this.result, + }); + + final String id; + final String userLabel; + final String actionType; + final String resourceType; + final DateTime timestamp; + final String result; +} + +/// Camada de dados isolada atrás de interface (no espírito de `AlertPublisher`/ +/// `AlertStore` do backend), para permitir mock enquanto os endpoints reais +/// não existem no `sinalacs_client`. +abstract interface class AdminDataSource { + Future fetchDashboardIndicators(); + + Future> fetchMicroAreas(); + + Future> fetchAlerts({String? microAreaName, AlertStatus? status}); + + Future> fetchAuditLogs(); + + /// Registra o próprio acesso do admin a uma tela sensível (PRD §4.2.2: + /// "Administrador (Sistema): R (auditado)"). + Future recordAccess({required String actionType, required String resourceType}); +} diff --git a/apps/admin/lib/core/data/mock_admin_data_source.dart b/apps/admin/lib/core/data/mock_admin_data_source.dart new file mode 100644 index 0000000..de352d1 --- /dev/null +++ b/apps/admin/lib/core/data/mock_admin_data_source.dart @@ -0,0 +1,138 @@ +import 'admin_data_source.dart'; + +/// Implementação mock de [AdminDataSource]. Todos os dados são fictícios +/// (nenhum dado real de paciente/UBS/ACS), no espírito do `AlertStore`/ +/// `AlertPublisher` fake usados nos testes do backend. +class MockAdminDataSource implements AdminDataSource { + MockAdminDataSource() + : _microAreas = List.unmodifiable(_seedMicroAreas), + _alerts = List.unmodifiable(_seedAlerts); + + final List _microAreas; + final List _alerts; + final List _auditLog = []; + int _auditSeq = 0; + + static final _seedMicroAreas = [ + const MicroAreaSummary( + id: 'ma-12', + name: 'Microárea 12 — Zona Rural', + acsName: 'Carla Nogueira', + acsEnrollmentId: 'ACS-001', + acsActive: true, + ), + const MicroAreaSummary( + id: 'ma-07', + name: 'Microárea 07 — Centro', + acsName: 'Bruno Faria', + acsEnrollmentId: 'ACS-014', + acsActive: true, + ), + const MicroAreaSummary( + id: 'ma-03', + name: 'Microárea 03 — Vila Esperança', + acsName: 'Sem ACS vinculado', + acsEnrollmentId: '—', + acsActive: false, + ), + ]; + + static final _seedAlerts = [ + AlertSummary( + id: 'alert-1', + patientLabel: 'Paciente #A18F', + microAreaName: 'Microárea 12 — Zona Rural', + riskLevel: RiskLevel.red, + status: AlertStatus.pending, + triggeredAt: DateTime(2026, 9, 11, 8, 12), + ), + AlertSummary( + id: 'alert-2', + patientLabel: 'Paciente #7C2E', + microAreaName: 'Microárea 12 — Zona Rural', + riskLevel: RiskLevel.red, + status: AlertStatus.acknowledged, + triggeredAt: DateTime(2026, 9, 11, 7, 40), + ), + AlertSummary( + id: 'alert-3', + patientLabel: 'Paciente #4D91', + microAreaName: 'Microárea 07 — Centro', + riskLevel: RiskLevel.yellow, + status: AlertStatus.acknowledged, + triggeredAt: DateTime(2026, 9, 11, 6, 55), + ), + AlertSummary( + id: 'alert-4', + patientLabel: 'Paciente #B6A0', + microAreaName: 'Microárea 07 — Centro', + riskLevel: RiskLevel.green, + status: AlertStatus.resolved, + triggeredAt: DateTime(2026, 9, 10, 19, 5), + ), + AlertSummary( + id: 'alert-5', + patientLabel: 'Paciente #E33C', + microAreaName: 'Microárea 03 — Vila Esperança', + riskLevel: RiskLevel.yellow, + status: AlertStatus.escalated, + triggeredAt: DateTime(2026, 9, 10, 15, 22), + ), + AlertSummary( + id: 'alert-6', + patientLabel: 'Paciente #19FA', + microAreaName: 'Microárea 03 — Vila Esperança', + riskLevel: RiskLevel.green, + status: AlertStatus.resolved, + triggeredAt: DateTime(2026, 9, 9, 11, 2), + ), + ]; + + @override + Future fetchDashboardIndicators() async { + final counts = { + for (final level in RiskLevel.values) + level: _alerts.where((alert) => alert.riskLevel == level).length, + }; + final openRed = _alerts + .where((alert) => alert.riskLevel == RiskLevel.red && alert.status == AlertStatus.pending) + .length; + final acknowledgedRed = _alerts + .where((alert) => alert.riskLevel == RiskLevel.red && alert.status == AlertStatus.acknowledged) + .length; + return DashboardIndicators( + countsByRisk: counts, + openRedAlerts: openRed, + acknowledgedRedAlerts: acknowledgedRed, + tmravSeconds: 78, + ); + } + + @override + Future> fetchMicroAreas() async => _microAreas; + + @override + Future> fetchAlerts({String? microAreaName, AlertStatus? status}) async { + return _alerts.where((alert) { + if (microAreaName != null && alert.microAreaName != microAreaName) return false; + if (status != null && alert.status != status) return false; + return true; + }).toList(); + } + + @override + Future> fetchAuditLogs() async => List.unmodifiable(_auditLog.reversed); + + @override + Future recordAccess({required String actionType, required String resourceType}) async { + _auditSeq++; + _auditLog.add(AuditLogEntry( + id: 'audit-$_auditSeq', + userLabel: 'admin.dev (Administrador)', + actionType: actionType, + resourceType: resourceType, + timestamp: DateTime.now(), + result: 'success', + )); + } +} diff --git a/apps/admin/lib/main.dart b/apps/admin/lib/main.dart new file mode 100644 index 0000000..b3fc110 --- /dev/null +++ b/apps/admin/lib/main.dart @@ -0,0 +1,6 @@ +import 'package:flutter/material.dart'; +import 'package:sinalacs_admin/app/app.dart'; + +void main() { + runApp(SinalAdminApp()); +} diff --git a/apps/admin/pubspec.lock b/apps/admin/pubspec.lock new file mode 100644 index 0000000..6c22124 --- /dev/null +++ b/apps/admin/pubspec.lock @@ -0,0 +1,268 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 + url: "https://pub.dev" + source: hosted + version: "3.0.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + platform: + dependency: transitive + description: + name: platform + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec + url: "https://pub.dev" + source: hosted + version: "3.2.0" + process: + dependency: transitive + description: + name: process + sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891" + url: "https://pub.dev" + source: hosted + version: "5.0.6" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" + source: hosted + version: "15.3.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "28b82ec894fed45dd71c23ba62d1af973ed97dd59a4f5790a4d38b0b13e5657e" + url: "https://pub.dev" + source: hosted + version: "3.2.0" +sdks: + dart: ">=3.10.0 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/apps/admin/pubspec.yaml b/apps/admin/pubspec.yaml index 350b27d..281286c 100644 --- a/apps/admin/pubspec.yaml +++ b/apps/admin/pubspec.yaml @@ -15,6 +15,8 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + integration_test: + sdk: flutter flutter_lints: ^3.0.0 flutter: diff --git a/apps/admin/test/admin_header_test.dart b/apps/admin/test/admin_header_test.dart new file mode 100644 index 0000000..0758a00 --- /dev/null +++ b/apps/admin/test/admin_header_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'support/layout_harness.dart'; + +/// O cabeçalho carrega duas linhas de texto e o selo "Acesso auditado". +/// +/// Em 72dp fixos de altura e menos de 400dp de largura os três competem pelo +/// mesmo espaço. O selo é informativo, não um controle, então em tela estreita +/// ele pode virar ícone — desde que continue anunciado a leitores de tela, que +/// é o que `Semantics(label:)` garante. +void main() { + testWidgets('mostra o selo de acesso auditado como ícone em tela estreita', (tester) async { + await abrirBackoffice(tester, tamanho: const Size(360, 800)); + + expect(find.byKey(const Key('admin_audit_badge')), findsOneWidget); + expect(tester.widget(find.byKey(const Key('admin_audit_badge'))), isA()); + expect(find.byType(Chip), findsNothing); + }); + + testWidgets('o selo continua anunciado a leitores de tela quando vira ícone', (tester) async { + await abrirBackoffice(tester, tamanho: const Size(360, 800)); + + expect( + find.bySemanticsLabel('Acesso auditado'), + findsOneWidget, + reason: 'trocar o rótulo por um ícone não pode remover a informação de quem usa leitor de tela', + ); + }); + + testWidgets('mostra o selo de acesso auditado como chip em tela larga', (tester) async { + await abrirBackoffice(tester, tamanho: const Size(1024, 768)); + + expect(find.byKey(const Key('admin_audit_badge')), findsOneWidget); + expect(tester.widget(find.byKey(const Key('admin_audit_badge'))), isA()); + }); +} diff --git a/apps/admin/test/admin_home_shell_test.dart b/apps/admin/test/admin_home_shell_test.dart new file mode 100644 index 0000000..3775085 --- /dev/null +++ b/apps/admin/test/admin_home_shell_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_admin/app/app.dart'; + +Future _login(WidgetTester tester) async { + await tester.pumpWidget(SinalAdminApp()); + await tester.tap(find.byKey(const Key('login_button'))); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('usa NavigationRail em telas largas (backoffice é desktop-first)', (tester) async { + await _login(tester); + + expect(find.byKey(const Key('admin_navigation_rail')), findsOneWidget); + expect(find.byKey(const Key('admin_navigation_bar')), findsNothing); + }); + + testWidgets('usa NavigationBar em telas estreitas', (tester) async { + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1.0; + + await _login(tester); + + expect(find.byKey(const Key('admin_navigation_bar')), findsOneWidget); + expect(find.byKey(const Key('admin_navigation_rail')), findsNothing); + }); + + testWidgets('navega entre os quatro destinos do backoffice', (tester) async { + await _login(tester); + + await tester.tap(find.text('Microáreas').last); + await tester.pumpAndSettle(); + expect(find.text('Microáreas e vínculo ACS'), findsOneWidget); + + await tester.tap(find.text('Alertas').last); + await tester.pumpAndSettle(); + expect(find.text('Alertas da UBS'), findsOneWidget); + + await tester.tap(find.text('Auditoria').last); + await tester.pumpAndSettle(); + expect(find.text('Logs de auditoria'), findsOneWidget); + }); +} diff --git a/apps/admin/test/alerts_filter_dynamic_test.dart b/apps/admin/test/alerts_filter_dynamic_test.dart new file mode 100644 index 0000000..aff75be --- /dev/null +++ b/apps/admin/test/alerts_filter_dynamic_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_admin/app/app.dart'; +import 'package:sinalacs_admin/core/data/admin_data_source.dart'; + +/// Duplo com uma microárea que não existe na antiga lista fixa do dropdown de +/// filtro (achado da revisão do PR: as opções eram três strings fixas no +/// código, então uma microárea nova nunca apareceria como filtro possível, +/// mesmo já tendo alertas). +class _CustomAreaDataSource implements AdminDataSource { + static const newArea = 'Microárea 99 — Nova Área'; + + @override + Future fetchDashboardIndicators() async => const DashboardIndicators( + countsByRisk: {}, + openRedAlerts: 0, + acknowledgedRedAlerts: 0, + tmravSeconds: 0, + ); + + @override + Future> fetchMicroAreas() async => const [ + MicroAreaSummary(id: 'ma-99', name: newArea, acsName: 'Fulana', acsEnrollmentId: 'ACS-099', acsActive: true), + ]; + + @override + Future> fetchAlerts({String? microAreaName, AlertStatus? status}) async => [ + AlertSummary( + id: 'alert-99', + patientLabel: 'Paciente #999', + microAreaName: newArea, + riskLevel: RiskLevel.green, + status: AlertStatus.resolved, + triggeredAt: DateTime(2026, 9, 15), + ), + ].where((a) => microAreaName == null || a.microAreaName == microAreaName).toList(); + + @override + Future> fetchAuditLogs() async => const []; + + @override + Future recordAccess({required String actionType, required String resourceType}) async {} +} + +void main() { + testWidgets('opções do filtro de microárea vêm de fetchMicroAreas(), não de uma lista fixa', (tester) async { + await tester.pumpWidget(SinalAdminApp(dataSource: _CustomAreaDataSource())); + await tester.tap(find.byKey(const Key('login_button'))); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Alertas').last); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('alerts_micro_area_filter'))); + await tester.pumpAndSettle(); + + expect(find.text(_CustomAreaDataSource.newArea), findsWidgets); + }); +} diff --git a/apps/admin/test/alerts_filters_layout_test.dart b/apps/admin/test/alerts_filters_layout_test.dart new file mode 100644 index 0000000..f1bba93 --- /dev/null +++ b/apps/admin/test/alerts_filters_layout_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'support/layout_harness.dart'; + +/// Os dois filtros de Alertas ficavam sempre lado a lado. +/// +/// Em 360dp cada `Expanded` recebia ~170dp: o rótulo do campo e o valor +/// selecionado disputavam o mesmo espaço e "Microárea 12 — Zona Rural" virava +/// reticências quase inteiras. `isExpanded` e `TextOverflow.ellipsis` +/// impediam o estouro, então nenhum teste de overflow pegaria isto — é um +/// defeito de legibilidade, e precisa da sua própria asserção. +void main() { + Offset posicaoDe(WidgetTester tester, String chave) => tester.getTopLeft(find.byKey(Key(chave))); + + testWidgets('empilha os filtros de microárea e status abaixo de 480dp', (tester) async { + await abrirBackoffice(tester, tamanho: const Size(360, 800)); + await irPara(tester, 'Alertas'); + + final microArea = posicaoDe(tester, 'alerts_micro_area_filter'); + final status = posicaoDe(tester, 'alerts_status_filter'); + + expect(status.dy, greaterThan(microArea.dy), reason: 'o filtro de status deveria estar abaixo, não ao lado'); + expect(status.dx, equals(microArea.dx), reason: 'empilhados, os dois começam na mesma margem'); + }); + + testWidgets('mantém os filtros lado a lado no layout largo', (tester) async { + await abrirBackoffice(tester, tamanho: const Size(1024, 768)); + await irPara(tester, 'Alertas'); + + final microArea = posicaoDe(tester, 'alerts_micro_area_filter'); + final status = posicaoDe(tester, 'alerts_status_filter'); + + expect(status.dy, equals(microArea.dy)); + expect(status.dx, greaterThan(microArea.dx)); + }); +} diff --git a/apps/admin/test/alerts_screen_test.dart b/apps/admin/test/alerts_screen_test.dart new file mode 100644 index 0000000..79f021e --- /dev/null +++ b/apps/admin/test/alerts_screen_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_admin/app/app.dart'; +import 'package:sinalacs_admin/core/data/admin_data_source.dart'; + +void main() { + testWidgets('deve filtrar alertas por microárea sem permitir reclassificação de risco', (tester) async { + await tester.pumpWidget(SinalAdminApp()); + await tester.tap(find.byKey(const Key('login_button'))); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Alertas').last); + await tester.pumpAndSettle(); + + expect(find.text('Alertas da UBS'), findsOneWidget); + expect(find.byKey(const Key('alert_alert-1')), findsOneWidget); + expect(find.byKey(const Key('alert_alert-3')), findsOneWidget); + + await tester.tap(find.byKey(const Key('alerts_micro_area_filter'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Microárea 12 — Zona Rural').last); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('alert_alert-1')), findsOneWidget); + expect(find.byKey(const Key('alert_alert-2')), findsOneWidget); + expect(find.byKey(const Key('alert_alert-3')), findsNothing); + + // Consulta somente leitura: não há dropdown/campo para alterar RiskLevel. + expect(find.byType(DropdownButtonFormField), findsNothing); + }); +} diff --git a/apps/admin/test/audit_log_screen_test.dart b/apps/admin/test/audit_log_screen_test.dart new file mode 100644 index 0000000..451d35e --- /dev/null +++ b/apps/admin/test/audit_log_screen_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_admin/app/app.dart'; + +void main() { + testWidgets('deve registrar e exibir o próprio acesso do admin às telas sensíveis', (tester) async { + await tester.pumpWidget(SinalAdminApp()); + await tester.tap(find.byKey(const Key('login_button'))); + await tester.pumpAndSettle(); + + // Visitar Microáreas e Alertas antes da Auditoria — cada visita deve gerar + // uma entrada própria via AdminDataSource.recordAccess (PRD §4.2.2). + await tester.tap(find.text('Microáreas').last); + await tester.pumpAndSettle(); + await tester.tap(find.text('Alertas').last); + await tester.pumpAndSettle(); + await tester.tap(find.text('Auditoria').last); + await tester.pumpAndSettle(); + + expect(find.text('Logs de auditoria'), findsOneWidget); + expect(find.text('view • micro_areas'), findsOneWidget); + expect(find.text('view • alerts'), findsOneWidget); + expect(find.text('view • audit_logs'), findsOneWidget); + expect(find.textContaining('admin.dev (Administrador)'), findsWidgets); + }); +} diff --git a/apps/admin/test/audit_timestamp_test.dart b/apps/admin/test/audit_timestamp_test.dart new file mode 100644 index 0000000..9cf36f6 --- /dev/null +++ b/apps/admin/test/audit_timestamp_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'support/layout_harness.dart'; + +/// A tela de auditoria imprimia `DateTime.toString()` cru. +/// +/// Em arquivo próprio, e não somado a `audit_log_screen_test.dart`, para que os +/// testes que já existiam continuem servindo de gabarito intocado do +/// comportamento desktop. +/// +/// Não usa `intl`: o backoffice não tem nenhuma dependência externa hoje, e um +/// formato pt-BR fixo basta — internacionalização não está no escopo. +void main() { + testWidgets('formata o horário do log como dd/MM/aaaa HH:mm', (tester) async { + await abrirBackoffice(tester, tamanho: const Size(1024, 768)); + await irPara(tester, 'Auditoria'); + + expect( + find.textContaining('.000'), + findsNothing, + reason: 'milissegundos e sufixo Z são ruído de DateTime.toString(), não informação de auditoria', + ); + expect(find.textContaining(RegExp(r'\d{2}/\d{2}/\d{4} \d{2}:\d{2}')), findsWidgets); + }); +} diff --git a/apps/admin/test/contrast_tokens_test.dart b/apps/admin/test/contrast_tokens_test.dart new file mode 100644 index 0000000..d3c5a08 --- /dev/null +++ b/apps/admin/test/contrast_tokens_test.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_admin/app/admin_theme.dart'; + +import 'support/contrast.dart'; + +/// Matriz determinística de contraste (WCAG 2.1 §1.4.3), no mesmo formato de +/// `apps/acs/test/contrast_tokens_test.dart` e +/// `apps/patient/test/contrast_tokens_test.dart`. +/// +/// O admin repetia o defeito que os outros dois apps já haviam corrigido: +/// `AdminColors.red`/`accent` usados como cor de PREENCHIMENTO (botão, faixa +/// lateral de risco) também eram usados como cor de TEXTO/ÍCONE sobre +/// `Card`/`AppBar` — papéis com exigências opostas. Cada linha abaixo declara +/// a SUPERFÍCIE real onde o texto/ícone é renderizado, não só o par de cores. +void main() { + const normalText = 4.5; + const largeTextOrUi = 3.0; + + /// (rótulo, texto/ícone, superfície, limiar) — um por par realmente + /// renderizado no app. + const cases = <(String, Color, Color, double)>[ + ('branco sobre scaffold', Colors.white, AdminColors.background, normalText), + ('branco sobre card', Colors.white, AdminColors.surfaceRaised, normalText), + ('branco sobre appbar', Colors.white, AdminColors.surface, normalText), + ('yellow sobre card (badge de risco amarelo)', AdminColors.yellow, AdminColors.surfaceRaised, normalText), + ('green sobre card (badge de risco verde)', AdminColors.green, AdminColors.surfaceRaised, normalText), + // Variantes de texto que o app passa a usar em vez do fill puro (ver os + // testes de documentação abaixo para a prova de que o fill sozinho falha). + ('redOnSurface sobre card', AdminColors.redOnSurface, AdminColors.surfaceRaised, normalText), + ('accentOnSurface sobre card', AdminColors.accentOnSurface, AdminColors.surfaceRaised, normalText), + ('redOnSurface sobre scaffold', AdminColors.redOnSurface, AdminColors.background, normalText), + ('accentOnSurface sobre scaffold', AdminColors.accentOnSurface, AdminColors.background, normalText), + // O eyebrow do cabeçalho renderiza sobre a AppBar (AdminColors.surface), + // não sobre um Card — é por isso que accentOnSurface precisa passar nas + // duas superfícies, e não só sobre card. + ('accentOnSurface sobre appbar (eyebrow do cabeçalho)', AdminColors.accentOnSurface, AdminColors.surface, normalText), + // Preenchimento: texto branco sobre a cor de fundo — a prova de que não + // precisamos trocar `red`/`accent` como fill. + ('branco sobre red (faixa/chip de risco)', Colors.white, AdminColors.red, largeTextOrUi), + ('branco sobre accent (botão do colorScheme)', Colors.white, AdminColors.accent, largeTextOrUi), + ]; + + for (final (label, fg, bg, threshold) in cases) { + test(label, () { + final ratio = contrastOn(fg, bg); + expect( + ratio, + greaterThanOrEqualTo(threshold), + reason: '$label: $ratio:1 abaixo do limiar $threshold:1 exigido pela WCAG', + ); + }); + } + + // Documenta os achados originais: os TOKENS DE PREENCHIMENTO, usados como + // texto/ícone direto, falham — é por isso que redOnSurface/accentOnSurface + // existem, em vez de reaproveitar red/accent também para texto. + test('red (fill) usado como texto sobre card fica abaixo de 4,5:1', () { + expect(contrastOn(AdminColors.red, AdminColors.surfaceRaised), lessThan(normalText)); + }); + + test('accent (fill) usado como texto sobre card fica abaixo de 4,5:1', () { + expect(contrastOn(AdminColors.accent, AdminColors.surfaceRaised), lessThan(normalText)); + }); + + test('accent (fill) usado como ícone sobre card fica abaixo de 3:1', () { + // O ícone do banner "Ambiente de desenvolvimento" é WCAG 1.4.11 (limiar + // 3:1 para componente não-textual), não 1.4.3 — mas falha nos dois. + expect(contrastOn(AdminColors.accent, AdminColors.surfaceRaised), lessThan(largeTextOrUi)); + }); + + test('o accentOnSurface do ACS (#60A5FA) passaria no contraste mas troca o matiz', () { + // Documenta por que o valor não foi copiado literalmente de acs_theme.dart: + // o par (fill, texto) do admin é indigo/indigo, não indigo/azul. #60A5FA + // atinge 4,5:1 aqui também — o problema não é matemático, é de identidade + // visual, e não aparece num teste de contraste isolado. + const acsAccentOnSurface = Color(0xFF60A5FA); + expect(contrastOn(acsAccentOnSurface, AdminColors.surfaceRaised), greaterThanOrEqualTo(normalText)); + }); +} diff --git a/apps/admin/test/error_handling_test.dart b/apps/admin/test/error_handling_test.dart new file mode 100644 index 0000000..bc83fc3 --- /dev/null +++ b/apps/admin/test/error_handling_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_admin/app/app.dart'; +import 'package:sinalacs_admin/core/data/mock_admin_data_source.dart'; + +import 'support/failing_admin_data_source.dart'; + +Future _loginTo(WidgetTester tester, FailingAdminDataSource dataSource, String destination) async { + await tester.pumpWidget(SinalAdminApp(dataSource: dataSource)); + await tester.tap(find.byKey(const Key('login_button'))); + await tester.pumpAndSettle(); + if (destination != 'Indicadores') { + await tester.tap(find.text(destination).last); + await tester.pumpAndSettle(); + } +} + +void main() { + group('erro e retry, por tela (achado da revisão do PR: erro não pode parecer vazio ou travar em spinner)', () { + testWidgets('Indicadores: mostra erro (não spinner infinito) e retry recupera', (tester) async { + final dataSource = FailingAdminDataSource(inner: MockAdminDataSource())..failNextIndicators = true; + await _loginTo(tester, dataSource, 'Indicadores'); + + expect(find.text('Não foi possível carregar os indicadores.'), findsOneWidget); + expect(find.text('Painel de Indicadores'), findsNothing); + + await tester.tap(find.text('Tentar novamente')); + await tester.pumpAndSettle(); + + expect(find.text('Painel de Indicadores'), findsOneWidget); + expect(find.text('Não foi possível carregar os indicadores.'), findsNothing); + }); + + testWidgets('Microáreas: recordAccess falhando não deve expor a lista sem auditar o acesso', (tester) async { + final dataSource = FailingAdminDataSource(inner: MockAdminDataSource())..failNextRecordAccess = true; + await _loginTo(tester, dataSource, 'Microáreas'); + + expect(find.text('Não foi possível carregar as microáreas.'), findsOneWidget); + expect(find.textContaining('Microárea 12'), findsNothing); + + await tester.tap(find.text('Tentar novamente')); + await tester.pumpAndSettle(); + + expect(find.textContaining('Microárea 12'), findsOneWidget); + }); + + testWidgets('Alertas: erro no carregamento não deve aparecer como "nenhum alerta"', (tester) async { + final dataSource = FailingAdminDataSource(inner: MockAdminDataSource())..failNextAlerts = true; + await _loginTo(tester, dataSource, 'Alertas'); + + expect(find.text('Não foi possível carregar os alertas.'), findsOneWidget); + expect(find.text('Nenhum alerta para o filtro selecionado.'), findsNothing); + + await tester.tap(find.text('Tentar novamente')); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('alert_alert-1')), findsOneWidget); + }); + + testWidgets('Auditoria: erro no carregamento não deve aparecer como "nenhum acesso registrado"', (tester) async { + final dataSource = FailingAdminDataSource(inner: MockAdminDataSource())..failNextAuditLogs = true; + await _loginTo(tester, dataSource, 'Auditoria'); + + expect(find.text('Não foi possível carregar os logs de auditoria.'), findsOneWidget); + expect(find.text('Nenhum acesso registrado ainda.'), findsNothing); + + await tester.tap(find.text('Tentar novamente')); + await tester.pumpAndSettle(); + + expect(find.textContaining('view • audit_logs'), findsOneWidget); + }); + }); + + testWidgets('filtros de Alertas mostram "Todas"/"Todos" fechados, não em branco (achado da revisão do PR)', (tester) async { + final dataSource = FailingAdminDataSource(inner: MockAdminDataSource()); + await _loginTo(tester, dataSource, 'Alertas'); + + expect(find.text('Todas'), findsOneWidget); + expect(find.text('Todos'), findsOneWidget); + }); +} diff --git a/apps/admin/test/layout_harness_sanity_test.dart b/apps/admin/test/layout_harness_sanity_test.dart new file mode 100644 index 0000000..fc02f4f --- /dev/null +++ b/apps/admin/test/layout_harness_sanity_test.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'support/layout_harness.dart'; + +/// Prova que o detector de estouro do harness realmente detecta. +/// +/// Sem este teste, um harness cego — checando antes da pintura, ou esquecendo +/// de chamar `takeException` — faria toda a suíte responsiva passar em verde +/// sem verificar nada, que é a falha mais cara possível aqui: ela não parece +/// uma falha. +void main() { + testWidgets('esperarSemEstouroDeLayout falha diante de um estouro proposital', (tester) async { + addTearDown(tester.view.reset); + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = const Size(200, 400); + + await tester.pumpWidget(const MaterialApp( + home: Scaffold(body: Row(children: [SizedBox(width: 500, height: 20)])), + )); + await tester.pumpAndSettle(); + + expect( + () => esperarSemEstouroDeLayout(tester, 'estouro proposital'), + throwsA(isA()), + ); + }); +} diff --git a/apps/admin/test/login_dev_gate_test.dart b/apps/admin/test/login_dev_gate_test.dart new file mode 100644 index 0000000..ed624ed --- /dev/null +++ b/apps/admin/test/login_dev_gate_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_admin/app/app.dart'; + +void main() { + group('login de desenvolvimento não pode funcionar fora de dev (achado da revisão do PR)', () { + testWidgets('com devLoginEnabled: false, o botão fica desabilitado e não navega', (tester) async { + await tester.pumpWidget(SinalAdminApp(devLoginEnabled: false)); + + final loginButton = tester.widget(find.byKey(const Key('login_button'))); + expect(loginButton.onPressed, isNull); + expect(find.byKey(const Key('dev_login_disabled_notice')), findsOneWidget); + + await tester.tap(find.byKey(const Key('login_button')), warnIfMissed: false); + await tester.pumpAndSettle(); + + expect(find.text('Painel de Indicadores'), findsNothing); + }); + + testWidgets('com devLoginEnabled: true, o botão funciona normalmente', (tester) async { + await tester.pumpWidget(SinalAdminApp(devLoginEnabled: true)); + + expect(find.byKey(const Key('dev_login_disabled_notice')), findsNothing); + + await tester.tap(find.byKey(const Key('login_button'))); + await tester.pumpAndSettle(); + + expect(find.text('Painel de Indicadores'), findsOneWidget); + }); + }); +} diff --git a/apps/admin/test/login_flow_test.dart b/apps/admin/test/login_flow_test.dart new file mode 100644 index 0000000..94a6c66 --- /dev/null +++ b/apps/admin/test/login_flow_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_admin/app/app.dart'; + +void main() { + testWidgets('deve autenticar e abrir o painel de indicadores', (tester) async { + await tester.pumpWidget(SinalAdminApp()); + + expect(find.byKey(const Key('dev_banner')), findsOneWidget); + + await tester.tap(find.byKey(const Key('login_button'))); + await tester.pumpAndSettle(); + + expect(find.text('Painel de Indicadores'), findsOneWidget); + expect(find.text('Vermelho'), findsOneWidget); + expect(find.text('Amarelo'), findsOneWidget); + expect(find.text('Verde'), findsOneWidget); + expect(find.text('Abertos (pendentes)'), findsOneWidget); + }); + + testWidgets('deve expor rótulo semântico e alvo de toque acessível no login do admin', (tester) async { + await tester.pumpWidget(SinalAdminApp()); + + final loginButton = tester.widget(find.byKey(const Key('login_button'))); + final minimumSize = loginButton.style?.minimumSize?.resolve({}) ?? const Size(0, 0); + + expect(find.bySemanticsLabel('Entrar no backoffice administrativo'), findsOneWidget); + expect(minimumSize.height, greaterThanOrEqualTo(48)); + expect(minimumSize.width, greaterThanOrEqualTo(48)); + }); +} diff --git a/apps/admin/test/micro_areas_screen_test.dart b/apps/admin/test/micro_areas_screen_test.dart new file mode 100644 index 0000000..379640a --- /dev/null +++ b/apps/admin/test/micro_areas_screen_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_admin/app/app.dart'; + +void main() { + testWidgets('deve listar microáreas com o ACS vinculado, somente leitura', (tester) async { + await tester.pumpWidget(SinalAdminApp()); + await tester.tap(find.byKey(const Key('login_button'))); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Microáreas').last); + await tester.pumpAndSettle(); + + expect(find.text('Microárea 12 — Zona Rural'), findsOneWidget); + expect(find.text('ACS: Carla Nogueira (ACS-001)'), findsOneWidget); + expect(find.text('Microárea 03 — Vila Esperança'), findsOneWidget); + expect(find.text('Sem ACS ativo'), findsOneWidget); + + // Somente leitura: nenhum botão de edição de vínculo nesta issue. + expect(find.byIcon(Icons.edit_outlined), findsNothing); + }); +} diff --git a/apps/admin/test/mock_admin_data_source_test.dart b/apps/admin/test/mock_admin_data_source_test.dart new file mode 100644 index 0000000..f98c4f0 --- /dev/null +++ b/apps/admin/test/mock_admin_data_source_test.dart @@ -0,0 +1,42 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_admin/core/data/admin_data_source.dart'; +import 'package:sinalacs_admin/core/data/mock_admin_data_source.dart'; + +void main() { + test('deve calcular contadores por risco e métricas de alerta vermelho a partir dos alertas mockados', () async { + final dataSource = MockAdminDataSource(); + + final indicators = await dataSource.fetchDashboardIndicators(); + + expect(indicators.countsByRisk[RiskLevel.red], 2); + expect(indicators.countsByRisk[RiskLevel.yellow], 2); + expect(indicators.countsByRisk[RiskLevel.green], 2); + expect(indicators.openRedAlerts, 1); + expect(indicators.acknowledgedRedAlerts, 1); + }); + + test('deve filtrar alertas por microárea e status combinados', () async { + final dataSource = MockAdminDataSource(); + + final filtered = await dataSource.fetchAlerts( + microAreaName: 'Microárea 07 — Centro', + status: AlertStatus.acknowledged, + ); + + expect(filtered, hasLength(1)); + expect(filtered.single.id, 'alert-3'); + }); + + test('deve registrar acessos em ordem cronológica reversa (mais recente primeiro)', () async { + final dataSource = MockAdminDataSource(); + + await dataSource.recordAccess(actionType: 'view', resourceType: 'micro_areas'); + await dataSource.recordAccess(actionType: 'view', resourceType: 'alerts'); + + final log = await dataSource.fetchAuditLogs(); + + expect(log, hasLength(2)); + expect(log.first.resourceType, 'alerts'); + expect(log.last.resourceType, 'micro_areas'); + }); +} diff --git a/apps/admin/test/responsive_layout_test.dart b/apps/admin/test/responsive_layout_test.dart new file mode 100644 index 0000000..2563c54 --- /dev/null +++ b/apps/admin/test/responsive_layout_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_admin/app/admin_layout.dart'; + +import 'support/layout_harness.dart'; + +/// Prova que as quatro telas cabem na janela, do celular ao desktop. +/// +/// O backoffice nasceu desktop-first (`spec/PRD_system.md` §2.1) e até agora só +/// rodava em web; o único teste responsivo que existia checava qual barra de +/// navegação aparece, nada sobre o conteúdo. Ver `layout_harness.dart` para o +/// porquê de cada cuidado na detecção de estouro. +void main() { + for (final (largura, altura) in const [(360.0, 800.0), (400.0, 800.0), (768.0, 1024.0), (1024.0, 768.0)]) { + testWidgets('não estoura o layout em nenhuma das quatro telas a ${largura.toInt()}x${altura.toInt()}', (tester) async { + await abrirBackoffice(tester, tamanho: Size(largura, altura)); + await percorrerBackofficeInteiro(tester, '${largura.toInt()}x${altura.toInt()}'); + }); + } + + testWidgets('não estoura o rail de navegação em paisagem de celular (800x360)', (tester) async { + // 800dp de largura passa de AdminBreakpoints.rail, então o NavigationRail + // entra — mas sobram ~288dp de altura depois do cabeçalho. `NavigationRail` + // só rola com `scrollable: true`, que não é o default; com os quatro + // destinos atuais o conteúdo cabe por poucos pixels. É uma folga que some + // ao acrescentar um quinto destino — daí o caso seguinte, com fonte + // ampliada, que é onde a margem acaba de verdade. + await abrirBackoffice(tester, tamanho: const Size(800, 360)); + + expect(find.byKey(const Key('admin_navigation_rail')), findsOneWidget); + await percorrerBackofficeInteiro(tester, 'paisagem de celular'); + }); + + testWidgets('não estoura o rail em paisagem de celular com fonte a 150%', (tester) async { + await abrirBackoffice(tester, tamanho: const Size(800, 360), escalaDeFonte: 1.5); + + expect(find.byKey(const Key('admin_navigation_rail')), findsOneWidget); + await percorrerBackofficeInteiro(tester, 'paisagem de celular com fonte a 150%'); + }); + + testWidgets('mantém o NavigationRail no tablet em 1280x800 (desktop-first preservado)', (tester) async { + await abrirBackoffice(tester, tamanho: const Size(1280, 800)); + + expect(find.byKey(const Key('admin_navigation_rail')), findsOneWidget); + expect(find.byKey(const Key('admin_navigation_bar')), findsNothing); + await percorrerBackofficeInteiro(tester, 'tablet 1280x800'); + }); + + testWidgets('troca para NavigationBar exatamente no ponto de quebra declarado', (tester) async { + await abrirBackoffice(tester, tamanho: const Size(AdminBreakpoints.rail, 800)); + expect(find.byKey(const Key('admin_navigation_rail')), findsOneWidget); + + redimensionar(tester, const Size(AdminBreakpoints.rail - 1, 800)); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('admin_navigation_bar')), findsOneWidget); + }); +} diff --git a/apps/admin/test/support/contrast.dart b/apps/admin/test/support/contrast.dart new file mode 100644 index 0000000..ea3c8ed --- /dev/null +++ b/apps/admin/test/support/contrast.dart @@ -0,0 +1,40 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +/// Luminância relativa WCAG (definição de 1.4.3), 0.0 (preto) a 1.0 (branco). +double relativeLuminance(Color color) { + double channel(double srgb) => + srgb <= 0.04045 ? srgb / 12.92 : math.pow((srgb + 0.055) / 1.055, 2.4).toDouble(); + // `Color.r/g/b` (0.0-1.0) substitui `.red/.green/.blue` (0-255), + // descontinuados nesta versão do Flutter. + return 0.2126 * channel(color.r) + 0.7152 * channel(color.g) + 0.0722 * channel(color.b); +} + +/// Razão de contraste WCAG entre duas cores OPACAS (sem canal alfa a +/// resolver). Para uma cor com alfa, componha antes com [compositeOver]. +double contrastRatio(Color a, Color b) { + final la = relativeLuminance(a); + final lb = relativeLuminance(b); + final hi = la > lb ? la : lb; + final lo = la > lb ? lb : la; + return (hi + 0.05) / (lo + 0.05); +} + +/// Resolve uma cor com transparência (`Colors.white54`, +/// `color.withValues(alpha: ...)`) contra o fundo em que ela realmente é +/// pintada — sem isto, a luminância de uma cor semitransparente não +/// corresponde ao que a tela mostra. +Color compositeOver(Color fg, Color bg) { + final a = fg.a; // 0.0-1.0 + double mix(double f, double b) => a * f + (1 - a) * b; + return Color.from( + alpha: 1.0, + red: mix(fg.r, bg.r), + green: mix(fg.g, bg.g), + blue: mix(fg.b, bg.b), + ); +} + +/// Razão de contraste já resolvendo o alfa de [fg] contra [bg]. +double contrastOn(Color fg, Color bg) => contrastRatio(compositeOver(fg, bg), bg); diff --git a/apps/admin/test/support/failing_admin_data_source.dart b/apps/admin/test/support/failing_admin_data_source.dart new file mode 100644 index 0000000..2f84f91 --- /dev/null +++ b/apps/admin/test/support/failing_admin_data_source.dart @@ -0,0 +1,67 @@ +import 'package:sinalacs_admin/core/data/admin_data_source.dart'; + +/// Duplo de teste que permite forçar falha em qualquer método, uma vez. +/// +/// Existe para provar o comportamento de erro/retry das telas (achado da +/// revisão do PR: `FutureBuilder` sem `hasError` deixava erro parecer "vazio" +/// ou spinner infinito). Cada campo `failNext*` é consumido uma única vez, o +/// que permite testar "falha, depois usuário tenta de novo e funciona". +class FailingAdminDataSource implements AdminDataSource { + FailingAdminDataSource({required this.inner}); + + final AdminDataSource inner; + + bool failNextIndicators = false; + bool failNextMicroAreas = false; + bool failNextAlerts = false; + bool failNextAuditLogs = false; + bool failNextRecordAccess = false; + + int recordAccessCalls = 0; + + @override + Future fetchDashboardIndicators() async { + if (failNextIndicators) { + failNextIndicators = false; + throw StateError('falha simulada: indicadores'); + } + return inner.fetchDashboardIndicators(); + } + + @override + Future> fetchMicroAreas() async { + if (failNextMicroAreas) { + failNextMicroAreas = false; + throw StateError('falha simulada: microáreas'); + } + return inner.fetchMicroAreas(); + } + + @override + Future> fetchAlerts({String? microAreaName, AlertStatus? status}) async { + if (failNextAlerts) { + failNextAlerts = false; + throw StateError('falha simulada: alertas'); + } + return inner.fetchAlerts(microAreaName: microAreaName, status: status); + } + + @override + Future> fetchAuditLogs() async { + if (failNextAuditLogs) { + failNextAuditLogs = false; + throw StateError('falha simulada: auditoria'); + } + return inner.fetchAuditLogs(); + } + + @override + Future recordAccess({required String actionType, required String resourceType}) async { + recordAccessCalls++; + if (failNextRecordAccess) { + failNextRecordAccess = false; + throw StateError('falha simulada: recordAccess'); + } + return inner.recordAccess(actionType: actionType, resourceType: resourceType); + } +} diff --git a/apps/admin/test/support/layout_harness.dart b/apps/admin/test/support/layout_harness.dart new file mode 100644 index 0000000..44f8053 --- /dev/null +++ b/apps/admin/test/support/layout_harness.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_admin/app/app.dart'; +import 'package:sinalacs_admin/core/data/admin_data_source.dart'; + +/// Ferramentas para provar que uma tela cabe na janela em que foi posta. +/// +/// Detectar estouro de layout em teste de widget tem três armadilhas, e errar +/// qualquer uma produz um teste que passa sempre — pior do que não ter teste: +/// +/// 1. `RenderFlex` só denuncia o estouro quando **pinta** o listrado amarelo e +/// preto (`DebugOverflowIndicatorMixin.paintOverflowIndicator` chamando +/// `FlutterError.reportError`). Checar antes de `pumpAndSettle()` não vê +/// nada. +/// 2. Item de `ListView` fora da viewport não é construído, logo não pinta e +/// nunca reclama. Todas as telas do backoffice são `ListView` na raiz, então +/// checar só o primeiro frame cobre a primeira dobra e mais nada — daí +/// [percorrerTelaInteira]. +/// 3. `takeException()` consome **uma** exceção por chamada. Duas telas +/// estourando com uma única checagem no fim reportariam uma só. + +/// Nomes dos quatro destinos, na ordem de `AdminDestination`. +const destinosDoBackoffice = ['Indicadores', 'Microáreas', 'Alertas', 'Auditoria']; + +/// Abre o backoffice já logado, numa janela de tamanho e escala de fonte fixos. +/// +/// [tamanho] é em pixels lógicos (dp) porque `devicePixelRatio` é forçado a 1. +Future abrirBackoffice( + WidgetTester tester, { + required Size tamanho, + double escalaDeFonte = 1.0, + AdminDataSource? dataSource, +}) async { + redimensionar(tester, tamanho, escalaDeFonte: escalaDeFonte); + addTearDown(tester.view.reset); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + + await tester.pumpWidget(SinalAdminApp(dataSource: dataSource, devLoginEnabled: true)); + + // `ensureVisible` antes do tap não é zelo: numa janela baixa (celular em + // paisagem, 360dp de altura) o botão fica abaixo da dobra e `tap` acerta o + // vazio sem lançar nada — os testes seguiriam medindo a tela de login + // achando que estavam no backoffice. + final entrar = find.byKey(const Key('login_button')); + await tester.ensureVisible(entrar); + await tester.pumpAndSettle(); + await tester.tap(entrar); + await tester.pumpAndSettle(); +} + +/// Troca o tamanho da janela de uma sessão já aberta — usado para girar a tela. +void redimensionar(WidgetTester tester, Size tamanho, {double escalaDeFonte = 1.0}) { + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = tamanho; + tester.platformDispatcher.textScaleFactorTestValue = escalaDeFonte; +} + +/// Falha se o frame recém-pintado reportou estouro de layout. +/// +/// Precisa vir depois de `pumpAndSettle()` — ver armadilha 1 no topo. +void esperarSemEstouroDeLayout(WidgetTester tester, String contexto) { + final erro = tester.takeException(); + expect(erro, isNull, reason: 'estouro de layout em $contexto: $erro'); +} + +/// Rola a tela até o fim, checando estouro a cada passo. +/// +/// Sem isso só a primeira dobra seria coberta — ver armadilha 2 no topo. +Future percorrerTelaInteira(WidgetTester tester, String contexto) async { + esperarSemEstouroDeLayout(tester, '$contexto (topo)'); + final lista = find.byType(Scrollable).last; + for (var passo = 1; passo <= 8; passo++) { + await tester.drag(lista, const Offset(0, -320)); + await tester.pumpAndSettle(); + esperarSemEstouroDeLayout(tester, '$contexto (rolagem $passo)'); + } +} + +/// Visita os quatro destinos, percorrendo cada um por inteiro. +Future percorrerBackofficeInteiro(WidgetTester tester, String contexto) async { + for (final destino in destinosDoBackoffice) { + await irPara(tester, destino); + await percorrerTelaInteira(tester, '$contexto / $destino'); + } +} + +Future irPara(WidgetTester tester, String destino) async { + final alvo = find.text(destino).last; + await tester.ensureVisible(alvo); + await tester.pumpAndSettle(); + await tester.tap(alvo); + await tester.pumpAndSettle(); + esperarSemEstouroDeLayout(tester, 'navegação para $destino'); +} + +/// Complemento de [esperarSemEstouroDeLayout] para o caso que ele não pega: +/// um widget mais largo que a janela sem `Flex` intermediário que reclame. +void esperarCaberNaLargura(WidgetTester tester, Finder alvo, String contexto) { + final larguraDaJanela = tester.view.physicalSize.width / tester.view.devicePixelRatio; + expect(tester.getSize(alvo).width, lessThanOrEqualTo(larguraDaJanela), reason: contexto); +} diff --git a/apps/admin/test/text_scale_test.dart b/apps/admin/test/text_scale_test.dart new file mode 100644 index 0000000..4a84f0f --- /dev/null +++ b/apps/admin/test/text_scale_test.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'support/layout_harness.dart'; + +/// WCAG 1.4.4 exige que o conteúdo sobreviva a 200% de escala de texto. +/// +/// O caminho é o layout aguentar, e não `MediaQuery.withClampedTextScaling`: +/// limitar a escala resolve o estouro desobedecendo à preferência de acessi- +/// bilidade de quem precisa dela. +void main() { + for (final escala in const [1.3, 2.0]) { + testWidgets('não estoura o layout com fonte a ${(escala * 100).toInt()}% em 360x800', (tester) async { + await abrirBackoffice(tester, tamanho: const Size(360, 800), escalaDeFonte: escala); + await percorrerBackofficeInteiro(tester, 'fonte a ${(escala * 100).toInt()}%'); + }); + } + + testWidgets('o cabeçalho cresce quando a escala de fonte aumenta em tempo de execução', (tester) async { + // Mudar a escala com o app aberto é o caso real: no Android a preferência + // de tamanho de fonte muda em Configurações, com o app já em segundo plano. + await abrirBackoffice(tester, tamanho: const Size(360, 800)); + final alturaPadrao = tester.getSize(find.byType(AppBar)).height; + + redimensionar(tester, const Size(360, 800), escalaDeFonte: 2.0); + await tester.pumpAndSettle(); + final alturaAmpliada = tester.getSize(find.byType(AppBar)).height; + + expect(alturaAmpliada, greaterThan(alturaPadrao)); + esperarSemEstouroDeLayout(tester, 'cabeçalho com fonte a 200%'); + }); +} diff --git a/apps/admin/test/touch_targets_test.dart b/apps/admin/test/touch_targets_test.dart new file mode 100644 index 0000000..ac87f22 --- /dev/null +++ b/apps/admin/test/touch_targets_test.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_admin/app/app.dart'; +import 'package:sinalacs_admin/core/data/mock_admin_data_source.dart'; + +import 'support/failing_admin_data_source.dart'; +import 'support/layout_harness.dart'; + +/// WCAG 2.5.5, na régua de `spec/ux_accessibility_assessment.md`: 48dp. +/// +/// Os apps ACS e paciente já fixam `minimumSize` nos botões; o backoffice +/// nunca precisou porque era só web com mouse. Em celular o default do +/// Material 3 para `OutlinedButton`/`TextButton` é 40dp de altura — abaixo do +/// mínimo. A correção mora no tema, não no widget, senão o próximo botão +/// adicionado nasce fora da régua de novo. +void main() { + const alturaMinima = 48.0; + + testWidgets('o botão de tentar novamente tem pelo menos 48dp de altura', (tester) async { + final dataSource = FailingAdminDataSource(inner: MockAdminDataSource())..failNextIndicators = true; + + await abrirBackoffice(tester, tamanho: const Size(360, 800), dataSource: dataSource); + + expect(find.text('Tentar novamente'), findsOneWidget); + final botao = tester.getSize(find.widgetWithText(OutlinedButton, 'Tentar novamente')); + expect(botao.height, greaterThanOrEqualTo(alturaMinima)); + }); + + testWidgets('o botão de entrar tem pelo menos 48dp de altura', (tester) async { + redimensionar(tester, const Size(360, 800)); + addTearDown(tester.view.reset); + + await tester.pumpWidget(SinalAdminApp(devLoginEnabled: true)); + await tester.pumpAndSettle(); + + final botao = tester.getSize(find.byKey(const Key('login_button'))); + expect(botao.height, greaterThanOrEqualTo(alturaMinima)); + }); + + testWidgets('os destinos da navegação inferior têm pelo menos 48dp de altura', (tester) async { + await abrirBackoffice(tester, tamanho: const Size(360, 800)); + + final barra = tester.getSize(find.byKey(const Key('admin_navigation_bar'))); + expect(barra.height, greaterThanOrEqualTo(alturaMinima)); + }); +} diff --git a/apps/admin/web/favicon.png b/apps/admin/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/apps/admin/web/favicon.png differ diff --git a/apps/admin/web/icons/Icon-192.png b/apps/admin/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/apps/admin/web/icons/Icon-192.png differ diff --git a/apps/admin/web/icons/Icon-512.png b/apps/admin/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/apps/admin/web/icons/Icon-512.png differ diff --git a/apps/admin/web/icons/Icon-maskable-192.png b/apps/admin/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/apps/admin/web/icons/Icon-maskable-192.png differ diff --git a/apps/admin/web/icons/Icon-maskable-512.png b/apps/admin/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/apps/admin/web/icons/Icon-maskable-512.png differ diff --git a/apps/admin/web/index.html b/apps/admin/web/index.html new file mode 100644 index 0000000..6e9115e --- /dev/null +++ b/apps/admin/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + SinalACS Admin + + + + + + + diff --git a/apps/admin/web/manifest.json b/apps/admin/web/manifest.json new file mode 100644 index 0000000..f3b3bdd --- /dev/null +++ b/apps/admin/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "sinalacs_admin", + "short_name": "sinalacs_admin", + "start_url": ".", + "display": "standalone", + "background_color": "#030712", + "theme_color": "#4F46E5", + "description": "Backoffice administrativo do SinalACS.", + "orientation": "any", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/apps/patient/.flutter-plugins-dependencies b/apps/patient/.flutter-plugins-dependencies index fbbb5f9..fd6a93f 100644 --- a/apps/patient/.flutter-plugins-dependencies +++ b/apps/patient/.flutter-plugins-dependencies @@ -1 +1 @@ -{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"connectivity_plus","path":"/home/codespace/.pub-cache/hosted/pub.dev/connectivity_plus-7.3.1/","native_build":true,"dependencies":[]},{"name":"flutter_local_notifications","path":"/home/codespace/.pub-cache/hosted/pub.dev/flutter_local_notifications-17.2.4/","native_build":true,"dependencies":[]},{"name":"geolocator_apple","path":"/home/codespace/.pub-cache/hosted/pub.dev/geolocator_apple-2.3.14/","shared_darwin_source":true,"native_build":true,"dependencies":[]},{"name":"sqflite_darwin","path":"/home/codespace/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/","shared_darwin_source":true,"native_build":true,"dependencies":[]},{"name":"sqflite_sqlcipher","path":"/home/codespace/.pub-cache/hosted/pub.dev/sqflite_sqlcipher-3.2.0/","native_build":true,"dependencies":[]}],"android":[{"name":"connectivity_plus","path":"/home/codespace/.pub-cache/hosted/pub.dev/connectivity_plus-7.3.1/","native_build":true,"dependencies":[]},{"name":"flutter_local_notifications","path":"/home/codespace/.pub-cache/hosted/pub.dev/flutter_local_notifications-17.2.4/","native_build":true,"dependencies":[]},{"name":"geolocator_android","path":"/home/codespace/.pub-cache/hosted/pub.dev/geolocator_android-4.6.2/","native_build":true,"dependencies":[]},{"name":"sqflite_android","path":"/home/codespace/.pub-cache/hosted/pub.dev/sqflite_android-2.4.0/","native_build":true,"dependencies":[]},{"name":"sqflite_sqlcipher","path":"/home/codespace/.pub-cache/hosted/pub.dev/sqflite_sqlcipher-3.2.0/","native_build":true,"dependencies":[]}],"macos":[{"name":"connectivity_plus","path":"/home/codespace/.pub-cache/hosted/pub.dev/connectivity_plus-7.3.1/","native_build":true,"dependencies":[]},{"name":"flutter_local_notifications","path":"/home/codespace/.pub-cache/hosted/pub.dev/flutter_local_notifications-17.2.4/","native_build":true,"dependencies":[]},{"name":"geolocator_apple","path":"/home/codespace/.pub-cache/hosted/pub.dev/geolocator_apple-2.3.14/","shared_darwin_source":true,"native_build":true,"dependencies":[]},{"name":"sqflite_darwin","path":"/home/codespace/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/","shared_darwin_source":true,"native_build":true,"dependencies":[]},{"name":"sqflite_sqlcipher","path":"/home/codespace/.pub-cache/hosted/pub.dev/sqflite_sqlcipher-3.2.0/","native_build":true,"dependencies":[]}],"linux":[{"name":"connectivity_plus","path":"/home/codespace/.pub-cache/hosted/pub.dev/connectivity_plus-7.3.1/","native_build":false,"dependencies":[]},{"name":"flutter_local_notifications_linux","path":"/home/codespace/.pub-cache/hosted/pub.dev/flutter_local_notifications_linux-4.0.1/","native_build":false,"dependencies":[]}],"windows":[{"name":"connectivity_plus","path":"/home/codespace/.pub-cache/hosted/pub.dev/connectivity_plus-7.3.1/","native_build":true,"dependencies":[]},{"name":"geolocator_windows","path":"/home/codespace/.pub-cache/hosted/pub.dev/geolocator_windows-0.2.5/","native_build":true,"dependencies":[]}],"web":[{"name":"connectivity_plus","path":"/home/codespace/.pub-cache/hosted/pub.dev/connectivity_plus-7.3.1/","dependencies":[]},{"name":"geolocator_web","path":"/home/codespace/.pub-cache/hosted/pub.dev/geolocator_web-4.1.4/","dependencies":[]}]},"dependencyGraph":[{"name":"connectivity_plus","dependencies":[]},{"name":"flutter_local_notifications","dependencies":["flutter_local_notifications_linux"]},{"name":"flutter_local_notifications_linux","dependencies":[]},{"name":"geolocator","dependencies":["geolocator_android","geolocator_apple","geolocator_web","geolocator_windows"]},{"name":"geolocator_android","dependencies":[]},{"name":"geolocator_apple","dependencies":[]},{"name":"geolocator_web","dependencies":[]},{"name":"geolocator_windows","dependencies":[]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]},{"name":"sqflite_sqlcipher","dependencies":[]}],"date_created":"2026-09-01 23:27:30.367246","version":"3.24.5","swift_package_manager_enabled":false} \ No newline at end of file +{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"connectivity_plus","path":"/home/rock/.pub-cache/hosted/pub.dev/connectivity_plus-7.3.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_local_notifications","path":"/home/rock/.pub-cache/hosted/pub.dev/flutter_local_notifications-17.2.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_apple","path":"/home/rock/.pub-cache/hosted/pub.dev/geolocator_apple-2.3.14/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"integration_test","path":"/home/rock/flutter/packages/integration_test/","native_build":true,"dependencies":[],"dev_dependency":true},{"name":"sqflite_darwin","path":"/home/rock/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_sqlcipher","path":"/home/rock/.pub-cache/hosted/pub.dev/sqflite_sqlcipher-3.2.0/","native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"connectivity_plus","path":"/home/rock/.pub-cache/hosted/pub.dev/connectivity_plus-7.3.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_local_notifications","path":"/home/rock/.pub-cache/hosted/pub.dev/flutter_local_notifications-17.2.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_android","path":"/home/rock/.pub-cache/hosted/pub.dev/geolocator_android-4.6.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"integration_test","path":"/home/rock/flutter/packages/integration_test/","native_build":true,"dependencies":[],"dev_dependency":true},{"name":"sqflite_android","path":"/home/rock/.pub-cache/hosted/pub.dev/sqflite_android-2.4.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_sqlcipher","path":"/home/rock/.pub-cache/hosted/pub.dev/sqflite_sqlcipher-3.2.0/","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"connectivity_plus","path":"/home/rock/.pub-cache/hosted/pub.dev/connectivity_plus-7.3.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_local_notifications","path":"/home/rock/.pub-cache/hosted/pub.dev/flutter_local_notifications-17.2.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_apple","path":"/home/rock/.pub-cache/hosted/pub.dev/geolocator_apple-2.3.14/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"/home/rock/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_sqlcipher","path":"/home/rock/.pub-cache/hosted/pub.dev/sqflite_sqlcipher-3.2.0/","native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"connectivity_plus","path":"/home/rock/.pub-cache/hosted/pub.dev/connectivity_plus-7.3.1/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"flutter_local_notifications_linux","path":"/home/rock/.pub-cache/hosted/pub.dev/flutter_local_notifications_linux-4.0.1/","native_build":false,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"connectivity_plus","path":"/home/rock/.pub-cache/hosted/pub.dev/connectivity_plus-7.3.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_windows","path":"/home/rock/.pub-cache/hosted/pub.dev/geolocator_windows-0.2.5/","native_build":true,"dependencies":[],"dev_dependency":false}],"web":[{"name":"connectivity_plus","path":"/home/rock/.pub-cache/hosted/pub.dev/connectivity_plus-7.3.1/","dependencies":[],"dev_dependency":false},{"name":"geolocator_web","path":"/home/rock/.pub-cache/hosted/pub.dev/geolocator_web-4.1.4/","dependencies":[],"dev_dependency":false}]},"dependencyGraph":[{"name":"connectivity_plus","dependencies":[]},{"name":"flutter_local_notifications","dependencies":["flutter_local_notifications_linux"]},{"name":"flutter_local_notifications_linux","dependencies":[]},{"name":"geolocator","dependencies":["geolocator_android","geolocator_apple","geolocator_web","geolocator_windows"]},{"name":"geolocator_android","dependencies":[]},{"name":"geolocator_apple","dependencies":[]},{"name":"geolocator_web","dependencies":[]},{"name":"geolocator_windows","dependencies":[]},{"name":"integration_test","dependencies":[]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]},{"name":"sqflite_sqlcipher","dependencies":[]}],"date_created":"2026-09-11 17:27:51.593179","version":"3.44.8","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/apps/patient/android/app/src/debug/AndroidManifest.xml b/apps/patient/android/app/src/debug/AndroidManifest.xml index 399f698..cfc90aa 100644 --- a/apps/patient/android/app/src/debug/AndroidManifest.xml +++ b/apps/patient/android/app/src/debug/AndroidManifest.xml @@ -4,4 +4,9 @@ to allow setting breakpoints, to provide hot reload, etc. --> + + + diff --git a/apps/patient/android/app/src/debug/res/xml/network_security_config.xml b/apps/patient/android/app/src/debug/res/xml/network_security_config.xml new file mode 100644 index 0000000..0e78459 --- /dev/null +++ b/apps/patient/android/app/src/debug/res/xml/network_security_config.xml @@ -0,0 +1,19 @@ + + + + + + 10.0.2.2 + + localhost + 127.0.0.1 + + diff --git a/apps/patient/integration_test/backend_connection_test.dart b/apps/patient/integration_test/backend_connection_test.dart new file mode 100644 index 0000000..83c7745 --- /dev/null +++ b/apps/patient/integration_test/backend_connection_test.dart @@ -0,0 +1,142 @@ +/// Conexão real do app do paciente com o backend Serverpod. +/// +/// Ao contrário dos testes em `test/`, estes NÃO são herméticos: exigem a stack +/// local de pé (`docker compose up`, com o database-seed concluído). Ficam em +/// `integration_test/` justamente por isso — `flutter test` não os executa, e o +/// CI segue hermético. +/// +/// flutter test integration_test \ +/// --dart-define=SINALACS_HOST=http://10.0.2.2:8080/ +/// +/// O default é 10.0.2.2, o host da máquina visto de dentro do emulador Android. +/// +/// PRIVACIDADE: só os UUIDs sintéticos do seed. Nenhum dado real de paciente, +/// e o token nunca é impresso. +library; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:sinalacs_client/sinalacs_client.dart'; +import 'package:sinalacs_patient/core/network/backend_client.dart'; +import 'package:sinalacs_patient/core/network/idempotency.dart'; +import 'package:sinalacs_patient/core/privacy/location_hash.dart'; + +const seedMicroAreaId = '00000000-0000-4000-8000-000000000003'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + late BackendClient backend; + + setUp(() => backend = BackendClient()); + tearDown(() => backend.close()); + + test('a stack responde à sonda de saúde', () async { + final health = await backend.health(); + + expect(health.status, 'ok'); + expect(health.dbConnected, isTrue, + reason: 'sem banco, createRedAlert falha por chave estrangeira'); + }); + + test('o login devolve uma sessão com a microárea do seed', () async { + final session = await backend.login(); + + expect(session.role, 'patient'); + // A microárea sai do payload do token; é o que define o tópico que o ACS + // assina, então tem de casar com o seed. + expect(session.microAreaId, seedMicroAreaId); + expect(session.isExpired(), isFalse); + }); + + test('a triagem é classificada pelo motor do servidor', () async { + final red = await backend.evaluateTriage( + chestPain: true, + difficultyBreathing: false, + fever: false, + persistentVomiting: false, + bleeding: false, + severeWeakness: false, + ); + final yellow = await backend.evaluateTriage( + chestPain: false, + difficultyBreathing: false, + fever: true, + persistentVomiting: false, + bleeding: false, + severeWeakness: false, + ); + final green = await backend.evaluateTriage( + chestPain: false, + difficultyBreathing: false, + fever: false, + persistentVomiting: false, + bleeding: false, + severeWeakness: false, + ); + + expect(red, RiskLevel.red); + expect(yellow, RiskLevel.yellow); + expect(green, RiskLevel.green); + }); + + test('a mesma resposta produz sempre o mesmo risco', () async { + // Determinismo é invariante (INV-02): a classificação não pode variar entre + // chamadas idênticas. + final results = []; + for (var attempt = 0; attempt < 3; attempt++) { + results.add(await backend.evaluateTriage( + chestPain: false, + difficultyBreathing: true, + fever: true, + persistentVomiting: false, + bleeding: false, + severeWeakness: false, + )); + } + + expect(results, everyElement(RiskLevel.red)); + }); + + test('o alerta vermelho é criado e o reenvio não duplica', () async { + await backend.login(); + final key = newIdempotencyKey(); + final hash = locationHashFrom(-23.55052, -46.633308); + + final first = await backend.createRedAlert(idempotencyKey: key, locationHash: hash); + final retry = await backend.createRedAlert(idempotencyKey: key, locationHash: hash); + + expect(first.alertId, isNotEmpty); + expect(first.status, AlertStatus.pending); + // Retry da MESMA tentativa: um segundo alerta aqui seria um chamado + // duplicado na fila do ACS. + expect(retry.alertId, first.alertId); + }); + + test('a chave de idempotência reusada com outra localização é recusada', () async { + await backend.login(); + final key = newIdempotencyKey(); + + await backend.createRedAlert( + idempotencyKey: key, + locationHash: locationHashFrom(-23.55052, -46.633308), + ); + + expect( + () => backend.createRedAlert( + idempotencyKey: key, + locationHash: locationHashFrom(-22.90685, -43.17290), + ), + throwsA(isA()), + ); + }); + + test('o hash de localização não revela a coordenada', () async { + // LGPD: o que trafega é o hash; a coordenada não pode ser legível nele. + final hash = locationHashFrom(-23.55052, -46.633308); + + expect(hash, hasLength(12)); + expect(hash, isNot(contains('23.55'))); + expect(hash, isNot(contains('46.63'))); + }); +} diff --git a/apps/patient/lib/app/app.dart b/apps/patient/lib/app/app.dart index 08b6031..fdfcc8c 100644 --- a/apps/patient/lib/app/app.dart +++ b/apps/patient/lib/app/app.dart @@ -1,23 +1,84 @@ import 'package:flutter/material.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show RiskLevel; import 'package:sinalacs_patient/app/patient_theme.dart'; +import 'package:sinalacs_patient/core/network/backend_client.dart'; +import 'package:sinalacs_patient/core/network/backend_scope.dart'; +import 'package:sinalacs_patient/core/network/idempotency.dart'; +import 'package:sinalacs_patient/core/privacy/location_hash.dart'; -class SinalAcsApp extends StatelessWidget { - const SinalAcsApp({super.key}); +class SinalAcsApp extends StatefulWidget { + const SinalAcsApp({super.key, this.backend}); + + /// Injetável para teste. Em execução normal é o [BackendClient] real. + final PatientBackend? backend; + + @override + State createState() => _SinalAcsAppState(); +} + +class _SinalAcsAppState extends State { + late final PatientBackend _backend = widget.backend ?? BackendClient(); + + @override + void dispose() { + // Só fecha o que este widget criou; um backend injetado é de quem injetou. + if (widget.backend == null) _backend.close(); + super.dispose(); + } @override Widget build(BuildContext context) { - return MaterialApp( - title: 'SinalACS Paciente', - debugShowCheckedModeBanner: false, - theme: buildPatientTheme(), - home: const PatientLoginScreen(), + return BackendScope( + backend: _backend, + child: MaterialApp( + title: 'SinalACS Paciente', + debugShowCheckedModeBanner: false, + theme: buildPatientTheme(), + home: const PatientLoginScreen(), + ), ); } } -class PatientLoginScreen extends StatelessWidget { +class PatientLoginScreen extends StatefulWidget { const PatientLoginScreen({super.key}); + @override + State createState() => _PatientLoginScreenState(); +} + +class _PatientLoginScreenState extends State { + bool _busy = false; + String? _error; + + /// Autentica de verdade contra `auth.developmentLogin` e só navega em caso de + /// sucesso. Antes a tela navegava incondicionalmente, ignorando o que era + /// digitado — não havia como saber se o backend estava sequer alcançável. + Future _enter() async { + setState(() { + _busy = true; + _error = null; + }); + + try { + await BackendScope.of(context).login(); + if (!mounted) return; + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (_) => const PatientHomeShell( + initialDestination: PatientDestination.triage, + ), + ), + ); + } on BackendFailure catch (failure) { + if (!mounted) return; + setState(() { + _busy = false; + _error = failure.message; + }); + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -71,14 +132,37 @@ class PatientLoginScreen extends StatelessWidget { width: double.infinity, child: FilledButton( key: const Key('enter_button'), - onPressed: () => Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (_) => const PatientHomeShell(initialDestination: PatientDestination.triage)), - ), + onPressed: _busy ? null : _enter, style: FilledButton.styleFrom(minimumSize: const Size(48, 52)), - child: const Text('Entrar sem senha'), + child: _busy + ? const SizedBox( + height: 22, + width: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Entrar sem senha'), ), ), ), + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: 16), + // SC 4.1.3: o erro aparece sem mover o foco — sem + // `liveRegion` o leitor de tela nunca saberia que + // o login falhou. + child: Semantics( + liveRegion: true, + child: Text( + key: const Key('login_error'), + _error!, + textAlign: TextAlign.center, + style: const TextStyle( + color: PatientColors.dangerOnSurface, + fontWeight: FontWeight.bold, + ), + ), + ), + ), const SizedBox(height: 12), SizedBox( width: double.infinity, @@ -181,6 +265,15 @@ class EmergencyScreen extends StatefulWidget { class _EmergencyScreenState extends State { String _state = 'Pronto para enviar'; + bool _busy = false; + + /// Chave da tentativa corrente. + /// + /// Gerada uma única vez por confirmação e mantida enquanto o envio não + /// conclui: se a pessoa tocar de novo depois de uma falha de rede, o servidor + /// reconhece a mesma chave e devolve o mesmo alerta em vez de criar um + /// segundo. Só é descartada quando o alerta é aceito. + String? _attemptKey; Future _sendAlert() async { final confirmed = await showDialog( @@ -195,7 +288,38 @@ class _EmergencyScreenState extends State { ), ); if (confirmed != true || !mounted) return; - setState(() => _state = 'Alerta enfileirado localmente'); + + final backend = BackendScope.of(context); + final key = _attemptKey ??= newIdempotencyKey(); + + setState(() { + _busy = true; + _state = 'Enviando alerta...'; + }); + + try { + final result = await backend.createRedAlert( + idempotencyKey: key, + // A localização real entra aqui quando o permissionamento de GPS for + // integrado; o contrato só aceita o hash, então a coordenada crua nunca + // sai do dispositivo (LGPD). + locationHash: unknownLocationHash, + ); + if (!mounted) return; + setState(() { + _busy = false; + _attemptKey = null; + _state = 'Alerta recebido pela equipe' + '${result.published ? '' : ' — aguardando a rede para notificar'}'; + }); + } on BackendFailure catch (failure) { + if (!mounted) return; + // A chave NÃO é limpa aqui: o retry precisa reusar a mesma tentativa. + setState(() { + _busy = false; + _state = failure.message; + }); + } } @override @@ -214,7 +338,7 @@ class _EmergencyScreenState extends State { height: 208, child: FilledButton( key: const Key('panic_button'), - onPressed: _sendAlert, + onPressed: _busy ? null : _sendAlert, style: FilledButton.styleFrom( backgroundColor: PatientColors.danger, shape: const CircleBorder(), @@ -225,7 +349,19 @@ class _EmergencyScreenState extends State { ), ), const SizedBox(height: 28), - Card(child: Padding(padding: const EdgeInsets.all(16), child: Column(children: [const Icon(Icons.location_on_outlined), const SizedBox(height: 8), const Text('A localização disponível será anexada ao alerta.', textAlign: TextAlign.center), const SizedBox(height: 8), Text(_state, style: const TextStyle(color: PatientColors.accent, fontWeight: FontWeight.bold))]))), + Card(child: Padding(padding: const EdgeInsets.all(16), child: Column(children: [ + const Icon(Icons.location_on_outlined), + const SizedBox(height: 8), + const Text('A localização disponível será anexada ao alerta.', textAlign: TextAlign.center), + const SizedBox(height: 8), + // SC 4.1.3: é a confirmação de que o alerta de emergência chegou à + // equipe — o ponto mais crítico do app para um leitor de tela + // anunciar sem depender de a pessoa varrer a tela de novo. + Semantics( + liveRegion: true, + child: Text(_state, style: const TextStyle(color: PatientColors.accentOnSurface, fontWeight: FontWeight.bold)), + ), + ]))), ], ); } @@ -239,44 +375,217 @@ class TriageScreen extends StatefulWidget { State createState() => _TriageScreenState(); } +/// Um sintoma do formulário, ligado ao parâmetro correspondente de +/// `triage.evaluate`. +/// +/// As perguntas são exatamente as seis que o motor do servidor conhece. Antes +/// eram três perguntas de múltipla escolha que **não** mapeavam para o contrato, +/// e o risco era calculado no cliente por comparação de string — duas regras de +/// risco no mesmo produto, o que viola o determinismo exigido pelo PRD (INV-02). +enum TriageSymptom { + chestPain('chest_pain', 'Você está com dor no peito?'), + difficultyBreathing('difficulty_breathing', 'Você está com falta de ar?'), + bleeding('bleeding', 'Você está com algum sangramento?'), + severeWeakness('severe_weakness', 'Você está com fraqueza intensa ou desmaio?'), + fever('fever', 'Você está com febre?'), + persistentVomiting('persistent_vomiting', 'Você está com vômitos que não param?'); + + const TriageSymptom(this.key, this.question); + + final String key; + final String question; +} + class _TriageScreenState extends State { - int _step = 0; - final List _answers = List.filled(3, null); - final _questions = const [ - ('Qual o sintoma principal?', ['Falta de ar ou cansaço intenso', 'Tontura ou pressão alterada', 'Dor localizada ou febre moderada', 'Dúvida de rotina ou medicação']), - ('O sintoma começou de forma súbita?', ['Sim, começou de repente', 'Não, começou aos poucos']), - ('Há algum sinal de agravamento?', ['Dor no peito ou sangramento', 'Sem sinal de agravamento']), - ]; + static const _symptoms = TriageSymptom.values; - String get _risk => _answers.any((answer) => answer == 'Dor no peito ou sangramento' || answer == 'Falta de ar ou cansaço intenso') ? 'Risco: Vermelho' : _answers.any((answer) => answer != null) ? 'Risco: Amarelo' : 'Risco: Verde'; + int _step = 0; + final Map _answers = {}; + bool _busy = false; + String? _error; + + /// Risco devolvido pelo servidor. `null` enquanto a triagem não foi concluída. + /// + /// Não existe cálculo de risco neste arquivo, e não deve passar a existir. + RiskLevel? _risk; + + bool get _isLastStep => _step == _symptoms.length - 1; + + Future _submit() async { + final answer = _answers[_symptoms[_step]]; + if (answer == null) return; + + if (!_isLastStep) { + setState(() => _step++); + return; + } + + setState(() { + _busy = true; + _error = null; + }); + + try { + final risk = await BackendScope.of(context).evaluateTriage( + chestPain: _answers[TriageSymptom.chestPain] ?? false, + difficultyBreathing: _answers[TriageSymptom.difficultyBreathing] ?? false, + fever: _answers[TriageSymptom.fever] ?? false, + persistentVomiting: _answers[TriageSymptom.persistentVomiting] ?? false, + bleeding: _answers[TriageSymptom.bleeding] ?? false, + severeWeakness: _answers[TriageSymptom.severeWeakness] ?? false, + ); + if (!mounted) return; + setState(() { + _busy = false; + _risk = risk; + }); + } on BackendFailure catch (failure) { + if (!mounted) return; + setState(() { + _busy = false; + _error = failure.message; + }); + } + } @override Widget build(BuildContext context) { - final question = _questions[_step]; + final risk = _risk; + if (risk != null) return _TriageResult(risk: risk, onContinue: widget.onComplete); + + final symptom = _symptoms[_step]; + final answer = _answers[symptom]; + return ListView( padding: const EdgeInsets.all(20), children: [ - Text('Passo ${_step + 1} de 3', style: const TextStyle(color: PatientColors.accent, fontWeight: FontWeight.bold)), + Text( + 'Passo ${_step + 1} de ${_symptoms.length}', + style: const TextStyle(color: PatientColors.accent, fontWeight: FontWeight.bold), + ), const SizedBox(height: 8), - LinearProgressIndicator(value: (_step + 1) / 3), + LinearProgressIndicator(value: (_step + 1) / _symptoms.length), const SizedBox(height: 24), - Text(question.$1, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + Text(symptom.question, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), const SizedBox(height: 16), - RadioGroup(groupValue: _answers[_step], onChanged: (value) => setState(() => _answers[_step] = value), child: Column(children: question.$2.map((answer) => Card(child: RadioListTile(key: Key(_step == 0 && answer.startsWith('Falta') ? 'difficulty_breathing' : _step == 2 && answer.startsWith('Dor') ? 'chest_pain' : 'triage_${_step}_${question.$2.indexOf(answer)}'), value: answer, title: Text(answer)))).toList())), + RadioGroup( + groupValue: answer, + onChanged: (value) => setState(() => _answers[symptom] = value ?? false), + child: Column( + children: [ + Card( + child: RadioListTile( + key: Key(symptom.key), + value: true, + title: const Text('Sim'), + ), + ), + Card( + child: RadioListTile( + key: Key('${symptom.key}_no'), + value: false, + title: const Text('Não'), + ), + ), + ], + ), + ), const SizedBox(height: 20), FilledButton( key: const Key('submit_triage'), - onPressed: _answers[_step] == null ? null : () { - if (_step < 2) { - setState(() => _step++); - } else { - widget.onComplete?.call(); - } - }, + onPressed: answer == null || _busy ? null : _submit, + style: FilledButton.styleFrom(minimumSize: const Size(48, 52)), + child: _busy + ? const SizedBox(height: 22, width: 22, child: CircularProgressIndicator(strokeWidth: 2)) + : Text(_isLastStep ? 'Concluir triagem' : 'Próxima pergunta'), + ), + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: 16), + child: Semantics( + liveRegion: true, + child: Text( + key: const Key('triage_error'), + _error!, + textAlign: TextAlign.center, + style: const TextStyle(color: PatientColors.dangerOnSurface, fontWeight: FontWeight.bold), + ), + ), + ), + ], + ); + } +} + +/// Exibe a classificação que veio do servidor. +/// +/// A cor é sinal clínico, nunca decoração: vermelho/amarelo/verde mapeiam +/// estritamente o [RiskLevel]. +class _TriageResult extends StatelessWidget { + const _TriageResult({required this.risk, this.onContinue}); + + final RiskLevel risk; + final VoidCallback? onContinue; + + @override + Widget build(BuildContext context) { + // `color` só é usada como texto/ícone sobre o card (nenhum botão herda + // este tom), por isso a variante `OnSurface` entra direto na tupla — + // `PatientColors.danger` e `PatientColors.accent` caem para 3.03:1 e + // 3.91:1 sobre `surfaceRaised`, abaixo de 4.5:1 (WCAG 1.4.3). + final (label, color, guidance) = switch (risk) { + RiskLevel.red => ( + 'Risco: Vermelho', + PatientColors.dangerOnSurface, + 'Sua equipe de saúde foi avisada com prioridade máxima. ' + 'Se piorar, ligue para o SAMU (192).', + ), + RiskLevel.yellow => ( + 'Risco: Amarelo', + const Color(0xFFE0A800), + 'Sua solicitação foi priorizada. O agente de saúde entrará em contato.', + ), + RiskLevel.green => ( + 'Risco: Verde', + PatientColors.accentOnSurface, + 'Sem sinais de urgência. Sua solicitação entrou na fila de rotina.', + ), + }; + + return ListView( + padding: const EdgeInsets.all(20), + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + Icon(Icons.verified_outlined, size: 44, color: color), + const SizedBox(height: 16), + Text( + key: const Key('triage_risk'), + label, + style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: color), + ), + const SizedBox(height: 12), + Text(guidance, textAlign: TextAlign.center), + const SizedBox(height: 8), + const Text( + 'Classificação feita pelo protocolo da equipe de saúde.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12, color: Colors.white54), + ), + ], + ), + ), + ), + const SizedBox(height: 20), + FilledButton( + key: const Key('triage_continue'), + onPressed: onContinue, style: FilledButton.styleFrom(minimumSize: const Size(48, 52)), - child: Text(_step == 2 ? 'Concluir triagem' : 'Próxima pergunta'), + child: const Text('Acompanhar solicitação'), ), - if (_step == 2) Padding(padding: const EdgeInsets.only(top: 16), child: Text(_risk, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold))), ], ); } diff --git a/apps/patient/lib/app/patient_theme.dart b/apps/patient/lib/app/patient_theme.dart index 792a0dc..ede0130 100644 --- a/apps/patient/lib/app/patient_theme.dart +++ b/apps/patient/lib/app/patient_theme.dart @@ -8,6 +8,16 @@ abstract final class PatientColors { static const accent = Color(0xFF0D9488); static const accentDark = Color(0xFF0F766E); static const danger = Color(0xFFDC2626); + + /// Variantes de `accent`/`danger` para uso como TEXTO/ÍCONE sobre + /// superfície escura (`surface`/`surfaceRaised`), não como preenchimento. + /// + /// `accent` só atinge 5.37:1 sobre o fundo do Scaffold — sobre + /// `surfaceRaised` cai para 3.91:1, abaixo de 4.5:1 (WCAG 1.4.3). `danger` + /// como preenchimento do botão de pânico continua correto (branco sobre + /// ele dá 4.83:1); como texto sobre card cairia para 3.03:1. + static const accentOnSurface = Color(0xFF2DD4BF); + static const dangerOnSurface = Color(0xFFF87171); } ThemeData buildPatientTheme() { diff --git a/apps/patient/lib/core/database/encrypted_database.dart b/apps/patient/lib/core/database/encrypted_database.dart deleted file mode 100644 index d86c2ce..0000000 --- a/apps/patient/lib/core/database/encrypted_database.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'dart:io'; - -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; -import 'package:sqflite_sqlcipher/sqflite.dart' as sqlcipher; - -class EncryptedLocalDatabase { - EncryptedLocalDatabase._(); - - static Future open({ - required String databaseName, - required String passphrase, - }) async { - if (!Platform.isAndroid && !Platform.isIOS) { - sqfliteFfiInit(); - } - - final databasePath = Platform.isAndroid || Platform.isIOS - ? await sqlcipher.getDatabasesPath() - : await databaseFactoryFfi.getDatabasesPath(); - final dbPath = '$databasePath/$databaseName'; - - if (Platform.isAndroid || Platform.isIOS) { - return sqlcipher.openDatabase( - dbPath, - password: passphrase, - version: 1, - onCreate: (db, version) async { - await db.execute('CREATE TABLE IF NOT EXISTS local_queue (id TEXT PRIMARY KEY)'); - }, - ); - } - - return databaseFactoryFfi.openDatabase( - dbPath, - options: OpenDatabaseOptions( - version: 1, - onCreate: (db, version) async { - await db.execute('CREATE TABLE IF NOT EXISTS local_queue (id TEXT PRIMARY KEY)'); - }, - ), - ); - } -} diff --git a/apps/patient/lib/core/network/auth_session.dart b/apps/patient/lib/core/network/auth_session.dart new file mode 100644 index 0000000..ac8f115 --- /dev/null +++ b/apps/patient/lib/core/network/auth_session.dart @@ -0,0 +1,84 @@ +import 'dart:convert'; + +/// Sessão autenticada contra o backend. +/// +/// **Não é autenticação institucional.** O token vem de +/// `auth.developmentLogin`, que só existe com `ENABLE_DEV_LOGIN=true` e serve +/// para validar a conexão, não para proteger dado real. +class AuthSession { + const AuthSession({ + required this.accessToken, + required this.tokenType, + required this.userId, + required this.role, + required this.microAreaId, + required this.expiresAt, + }); + + final String accessToken; + final String tokenType; + final String userId; + final String role; + + /// Microárea do usuário. Restringe o acesso ao território (INV do PRD) e, no + /// app do ACS, define o tópico MQTT assinado. + final String? microAreaId; + + final DateTime expiresAt; + + /// Lê o payload do token emitido por `DevelopmentAuthService`. + /// + /// O token é um JWT HS256 cujo payload carrega `sub`, `role`, + /// `micro_area_id` e `exp`. A assinatura **não** é verificada aqui: verificar + /// é papel do servidor, e o app não tem — nem deve ter — o segredo HMAC. A + /// leitura serve apenas para o app conhecer a própria microárea e a + /// expiração, o que evita um endpoint extra só para isso. + /// + /// Devolve `null` para qualquer token malformado; quem chama decide o que + /// mostrar. Nunca registre o token em log. + static AuthSession? tryParse(String accessToken, String tokenType) { + final sections = accessToken.split('.'); + if (sections.length != 3) return null; + + try { + final decoded = utf8.decode( + base64Url.decode(base64Url.normalize(sections[1])), + ); + final payload = jsonDecode(decoded) as Map; + + final exp = payload['exp']; + final sub = payload['sub']; + final role = payload['role']; + if (exp is! int || sub is! String || role is! String) return null; + + return AuthSession( + accessToken: accessToken, + tokenType: tokenType, + userId: sub, + role: role, + microAreaId: payload['micro_area_id'] as String?, + expiresAt: DateTime.fromMillisecondsSinceEpoch( + exp * 1000, + isUtc: true, + ), + ); + } on FormatException { + return null; + } on TypeError { + return null; + } + } + + /// O token de desenvolvimento vive 15 minutos. + /// + /// A margem existe para não enviar um token que expira no meio da viagem: + /// sem ela, um fluxo longo falha com erro de permissão em vez de + /// reautenticar, que é um sintoma bem mais confuso de diagnosticar. + bool isExpired({ + DateTime? now, + Duration margin = const Duration(seconds: 30), + }) { + final reference = (now ?? DateTime.now().toUtc()).add(margin); + return !expiresAt.isAfter(reference); + } +} diff --git a/apps/patient/lib/core/network/backend_client.dart b/apps/patient/lib/core/network/backend_client.dart new file mode 100644 index 0000000..93bbc0c --- /dev/null +++ b/apps/patient/lib/core/network/backend_client.dart @@ -0,0 +1,198 @@ +import 'package:sinalacs_client/sinalacs_client.dart'; +import 'package:sinalacs_patient/core/network/auth_session.dart'; +import 'package:sinalacs_patient/core/network/backend_config.dart'; + +/// Falha já traduzida para a pessoa que está usando o app. +/// +/// O backend é RPC tipado, então o erro chega como exceção declarada no +/// `.spy.yaml` — não como código HTTP. Traduzir aqui mantém a UI livre de +/// `try/catch` espalhado e garante mensagem em português. +class BackendFailure implements Exception { + const BackendFailure(this.message, {this.isRecoverable = true}); + + final String message; + + /// `false` quando repetir a mesma ação não deve resolver (rota desligada, + /// permissão negada). + final bool isRecoverable; + + @override + String toString() => message; +} + +/// Contrato do backend visto pela UI do paciente. +/// +/// A UI depende desta abstração, nunca do [Client] gerado — mesmo padrão que o +/// servidor usa entre `application/` e `infrastructure/` (`AlertPublisher`, +/// `AlertStore`). É o que permite testar as telas sem rede. +abstract class PatientBackend { + AuthSession? get session; + + bool get isAuthenticated; + + Future health(); + + Future login(); + + Future evaluateTriage({ + required bool chestPain, + required bool difficultyBreathing, + required bool fever, + required bool persistentVomiting, + required bool bleeding, + required bool severeWeakness, + }); + + Future createRedAlert({ + required String idempotencyKey, + required String locationHash, + }); + + void close(); +} + +/// Fachada do backend para o app do paciente. +/// +/// Mantém um único [Client] e a [AuthSession] corrente. O cliente é o mesmo +/// código gerado por `serverpod generate` que o servidor usa, então qualquer +/// divergência de contrato quebra em tempo de compilação, não em produção. +class BackendClient implements PatientBackend { + BackendClient({String? host}) + : _client = Client(host ?? BackendConfig.host) + ..connectivityMonitor = null; + + final Client _client; + + AuthSession? _session; + + @override + AuthSession? get session => _session; + + @override + bool get isAuthenticated { + final current = _session; + return current != null && !current.isExpired(); + } + + /// Token válido para as chamadas que exigem autenticação. + /// + /// Reautentica sozinho quando o token de 15 minutos expirou — sem isso, um + /// fluxo demorado falha com erro de permissão, que esconde a causa real. + Future _requireToken() async { + if (!isAuthenticated) await login(); + final current = _session; + if (current == null) { + throw const BackendFailure('Sessão não iniciada.', isRecoverable: false); + } + return current.accessToken; + } + + @override + Future health() { + return _guard(() => _client.health.check()); + } + + /// Autentica como paciente. Ver ressalvas em [AuthSession]. + @override + Future login() async { + final result = await _guard( + () => _client.auth.developmentLogin(role: 'patient'), + ); + + final session = AuthSession.tryParse(result.accessToken, result.tokenType); + if (session == null) { + throw const BackendFailure( + 'O servidor devolveu um token que o aplicativo não entendeu.', + isRecoverable: false, + ); + } + + _session = session; + return session; + } + + /// Classificação de risco pelo motor determinístico do servidor. + /// + /// A regra de risco vive **só** no servidor (INV-02): o app envia sintomas e + /// exibe o que voltar. Não recalcule nem ajuste o resultado aqui. + @override + Future evaluateTriage({ + required bool chestPain, + required bool difficultyBreathing, + required bool fever, + required bool persistentVomiting, + required bool bleeding, + required bool severeWeakness, + }) async { + final result = await _guard( + () => _client.triage.evaluate( + chestPain: chestPain, + difficultyBreathing: difficultyBreathing, + fever: fever, + persistentVomiting: persistentVomiting, + bleeding: bleeding, + severeWeakness: severeWeakness, + ), + ); + return result.risk; + } + + /// Dispara o alerta vermelho. + /// + /// [idempotencyKey] precisa ser **estável para a mesma tentativa do usuário**: + /// é o que impede que um retry vire um segundo alerta. [locationHash] é o + /// hash da localização — coordenada crua nunca sai do dispositivo (LGPD). + @override + Future createRedAlert({ + required String idempotencyKey, + required String locationHash, + }) async { + final token = await _requireToken(); + return _guard( + () => _client.alerts.createRedAlert( + accessToken: token, + idempotencyKey: idempotencyKey, + locationHash: locationHash, + ), + ); + } + + @override + void close() => _client.close(); + + /// Traduz as exceções tipadas do backend para [BackendFailure]. + Future _guard(Future Function() call) async { + try { + return await call(); + } on EndpointDisabledException { + // ENABLE_DEV_LOGIN=false. O servidor responde como se a rota não + // existisse, de propósito. + throw const BackendFailure( + 'O acesso de desenvolvimento está desativado neste servidor.', + isRecoverable: false, + ); + } on AlertPermissionException { + throw const BackendFailure( + 'Este acesso não tem permissão para esta ação.', + isRecoverable: false, + ); + } on AlertValidationException catch (error) { + throw BackendFailure(error.message, isRecoverable: false); + } on AlertDispatchUnavailableException { + // O alerta FOI gravado; só a publicação imediata falhou. Dizer que + // "falhou" seria mentira e faria a pessoa tentar de novo sem necessidade. + throw const BackendFailure( + 'Alerta registrado. A rede está instável e ele será entregue à equipe ' + 'assim que a conexão voltar.', + ); + } on ServerpodClientException catch (error) { + throw BackendFailure( + 'Não foi possível falar com o servidor (${error.statusCode}).', + ); + } catch (_) { + throw const BackendFailure( + 'Sem conexão com o servidor. Verifique a rede e tente de novo.', + ); + } + } +} diff --git a/apps/patient/lib/core/network/backend_config.dart b/apps/patient/lib/core/network/backend_config.dart new file mode 100644 index 0000000..81d0ef5 --- /dev/null +++ b/apps/patient/lib/core/network/backend_config.dart @@ -0,0 +1,19 @@ +/// Endereço do backend, configurável em tempo de compilação. +/// +/// O default é o host da máquina de desenvolvimento visto de dentro do +/// emulador Android (`10.0.2.2`). Para outros alvos: +/// +/// flutter run --dart-define=SINALACS_HOST=http://localhost:8080/ +/// flutter run --dart-define=SINALACS_HOST=http://192.168.0.10:8080/ +/// +/// Cleartext HTTP só é permitido em builds de debug e só para esses hosts — +/// ver android/app/src/debug/res/xml/network_security_config.xml. +class BackendConfig { + const BackendConfig._(); + + /// A barra final é exigida pelo cliente Serverpod. + static const String host = String.fromEnvironment( + 'SINALACS_HOST', + defaultValue: 'http://10.0.2.2:8080/', + ); +} diff --git a/apps/patient/lib/core/network/backend_scope.dart b/apps/patient/lib/core/network/backend_scope.dart new file mode 100644 index 0000000..5c70e77 --- /dev/null +++ b/apps/patient/lib/core/network/backend_scope.dart @@ -0,0 +1,26 @@ +import 'package:flutter/widgets.dart'; +import 'package:sinalacs_patient/core/network/backend_client.dart'; + +/// Disponibiliza o [PatientBackend] para a árvore de widgets. +/// +/// Existe para que as telas não construam o próprio cliente: em teste, injeta-se +/// um duplo; em execução, o [BackendClient] real. +class BackendScope extends InheritedWidget { + const BackendScope({ + required this.backend, + required super.child, + super.key, + }); + + final PatientBackend backend; + + static PatientBackend of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType(); + assert(scope != null, 'Nenhum BackendScope acima deste widget.'); + return scope!.backend; + } + + @override + bool updateShouldNotify(BackendScope oldWidget) => + backend != oldWidget.backend; +} diff --git a/apps/patient/lib/core/network/idempotency.dart b/apps/patient/lib/core/network/idempotency.dart new file mode 100644 index 0000000..659bb09 --- /dev/null +++ b/apps/patient/lib/core/network/idempotency.dart @@ -0,0 +1,21 @@ +import 'dart:math'; + +/// Gera a chave de idempotência de um alerta vermelho. +/// +/// O servidor usa esta chave para não criar um segundo alerta quando a mesma +/// tentativa é reenviada (`alerts.createRedAlert` grava a chave junto com o +/// alerta). Por isso a chave precisa ser gerada **uma vez por tentativa do +/// usuário** e reaproveitada em todos os retries dessa tentativa — gerar uma +/// nova a cada envio anularia a proteção e duplicaria o alerta. +String newIdempotencyKey() { + final random = Random.secure(); + final bytes = List.generate(16, (_) => random.nextInt(256)); + + // Formato UUID v4, só para manter a chave reconhecível em log do servidor. + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + + final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-' + '${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20)}'; +} diff --git a/apps/patient/lib/core/privacy/location_hash.dart b/apps/patient/lib/core/privacy/location_hash.dart new file mode 100644 index 0000000..7246d70 --- /dev/null +++ b/apps/patient/lib/core/privacy/location_hash.dart @@ -0,0 +1,26 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; + +/// Hash de localização enviado ao backend no lugar da coordenada. +/// +/// **LGPD:** latitude e longitude cruas não saem do dispositivo e não vão para +/// log. O servidor só precisa distinguir e agrupar locais, não sabê-los — por +/// isso `alerts.createRedAlert` recebe `locationHash`, nunca o par de +/// coordenadas. +/// +/// O esquema (sha256 sobre a coordenada com 6 casas, truncado em 12) é o mesmo +/// já usado no app do ACS, para que os dois lados produzam o mesmo hash para o +/// mesmo ponto. +String locationHashFrom(double latitude, double longitude) { + final normalized = + '${latitude.toStringAsFixed(6)}:${longitude.toStringAsFixed(6)}'; + return sha256.convert(utf8.encode(normalized)).toString().substring(0, 12); +} + +/// Hash usado quando a localização não está disponível. +/// +/// Um alerta vermelho sem GPS ainda precisa chegar à equipe — descartá-lo por +/// falta de coordenada violaria "alerta vermelho nunca some em silêncio". O +/// valor é constante e explicitamente reconhecível como ausência de local. +const String unknownLocationHash = 'sem-local-00'; diff --git a/apps/patient/pubspec.lock b/apps/patient/pubspec.lock index 63dbe8e..0ec166c 100644 --- a/apps/patient/pubspec.lock +++ b/apps/patient/pubspec.lock @@ -29,34 +29,26 @@ packages: dependency: transitive description: name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.3.0" - charcode: - dependency: transitive - description: - name: charcode - sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a - url: "https://pub.dev" - source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: name: clock - sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" collection: dependency: transitive description: name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.1" connectivity_plus: dependency: "direct main" description: @@ -74,21 +66,13 @@ packages: source: hosted version: "2.1.0" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf url: "https://pub.dev" source: hosted version: "3.0.7" - csslib: - dependency: transitive - description: - name: csslib - sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" - url: "https://pub.dev" - source: hosted - version: "1.0.2" dbus: dependency: transitive description: @@ -97,22 +81,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.12" - event_bus: - dependency: transitive - description: - name: event_bus - sha256: "1a55e97923769c286d295240048fc180e7b0768902c3c2e869fe059aafa15304" - url: "https://pub.dev" - source: hosted - version: "2.0.1" fake_async: dependency: transitive description: name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.3.3" ffi: dependency: transitive description: @@ -121,6 +97,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" fixnum: dependency: transitive description: @@ -134,6 +118,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_lints: dependency: "direct dev" description: @@ -176,6 +165,11 @@ packages: description: flutter source: sdk version: "0.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" geolocator: dependency: "direct main" description: @@ -224,38 +218,51 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.5" - html: + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: dependency: transitive description: - name: html - sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" url: "https://pub.dev" source: hosted - version: "0.15.6" + version: "4.1.2" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" leak_tracker: dependency: transitive description: name: leak_tracker - sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "10.0.5" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.5" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" lints: dependency: transitive description: @@ -268,34 +275,26 @@ packages: dependency: transitive description: name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.16+1" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 - url: "https://pub.dev" - source: hosted - version: "1.15.0" - mqtt_client: - dependency: "direct main" - description: - name: mqtt_client - sha256: "37aae360fac0b3322cb37267696c1535e1ad2984a13064d877273ffe4b16ca7a" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "10.5.1" + version: "1.18.0" nm: dependency: transitive description: @@ -308,10 +307,10 @@ packages: dependency: transitive description: name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.dev" source: hosted - version: "1.9.0" + version: "1.9.1" petitparser: dependency: transitive description: @@ -336,11 +335,58 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + process: + dependency: transitive + description: + name: process + sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891" + url: "https://pub.dev" + source: hosted + version: "5.0.6" + serverpod_auth_core_client: + dependency: transitive + description: + name: serverpod_auth_core_client + sha256: "60b89a1b854fd9c7d589737befd7e4d0cafe047600d65b12b137d827e4c6f6ba" + url: "https://pub.dev" + source: hosted + version: "3.4.13" + serverpod_auth_idp_client: + dependency: transitive + description: + name: serverpod_auth_idp_client + sha256: fb97444ef0df6ac0eca66be884f1107d943df29fb999b6e9a5ad2eb95de9010e + url: "https://pub.dev" + source: hosted + version: "3.4.13" + serverpod_client: + dependency: "direct main" + description: + name: serverpod_client + sha256: "8388185c0eefe2c356c271427d37427cf2b366b3c095da79b7d8be9940cb10bd" + url: "https://pub.dev" + source: hosted + version: "3.4.13" + serverpod_serialization: + dependency: transitive + description: + name: serverpod_serialization + sha256: "807afab39b758f7b21a870af87abfaa24ebdf49aa5d1d9d122b0f3e51157873e" + url: "https://pub.dev" + source: hosted + version: "3.4.13" + sinalacs_client: + dependency: "direct main" + description: + path: "../../backend/sinalacs_client" + relative: true + source: path + version: "0.0.0" sky_engine: dependency: transitive description: flutter source: sdk - version: "0.0.99" + version: "0.0.0" source_span: dependency: transitive description: @@ -417,18 +463,18 @@ packages: dependency: transitive description: name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" url: "https://pub.dev" source: hosted - version: "1.11.1" + version: "1.12.1" stream_channel: dependency: transitive description: name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.4" string_scanner: dependency: transitive description: @@ -437,6 +483,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" synchronized: dependency: transitive description: @@ -457,10 +511,10 @@ packages: dependency: transitive description: name: test_api - sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "0.7.11" timezone: dependency: transitive description: @@ -477,22 +531,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" - universal_html: - dependency: transitive - description: - name: universal_html - sha256: "56536254004e24d9d8cfdb7dbbf09b74cf8df96729f38a2f5c238163e3d58971" - url: "https://pub.dev" - source: hosted - version: "2.2.4" - universal_io: - dependency: transitive - description: - name: universal_io - sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" - url: "https://pub.dev" - source: hosted - version: "2.2.2" uuid: dependency: transitive description: @@ -505,10 +543,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" vm_service: dependency: transitive description: @@ -525,6 +563,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "28b82ec894fed45dd71c23ba62d1af973ed97dd59a4f5790a4d38b0b13e5657e" + url: "https://pub.dev" + source: hosted + version: "3.2.0" xdg_directories: dependency: transitive description: @@ -541,6 +603,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.5.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" sdks: - dart: ">=3.5.0 <4.0.0" + dart: ">=3.10.0-0 <4.0.0" flutter: ">=3.24.0" diff --git a/apps/patient/pubspec.yaml b/apps/patient/pubspec.yaml index 7bc654b..f17ed02 100644 --- a/apps/patient/pubspec.yaml +++ b/apps/patient/pubspec.yaml @@ -5,22 +5,32 @@ publish_to: 'none' version: 1.0.0 environment: - sdk: '>=3.3.0 <4.0.0' + # ^3.8.0 é exigido por sinalacs_client; era '>=3.3.0 <4.0.0'. + sdk: '^3.8.0' dependencies: flutter: sdk: flutter + # Cliente RPC tipado gerado por `serverpod generate`. É membro do workspace + # backend/, e a dependência por path a partir de fora resolve — ver + # video/rpc_demo, que usa exatamente este arranjo. + sinalacs_client: + path: ../../backend/sinalacs_client + # Fixado na mesma versão que sinalacs_client declara; divergir quebra a resolução. + serverpod_client: 3.4.13 + crypto: ^3.0.0 sqflite: ^2.3.0 sqflite_common_ffi: ^2.3.0 sqflite_sqlcipher: ^3.2.0 connectivity_plus: ^7.3.1 - mqtt_client: ^10.0.0 geolocator: ^12.0.0 flutter_local_notifications: ^17.0.0 dev_dependencies: flutter_test: sdk: flutter + integration_test: + sdk: flutter flutter_lints: ^3.0.0 flutter: diff --git a/apps/patient/test/contrast_tokens_test.dart b/apps/patient/test/contrast_tokens_test.dart new file mode 100644 index 0000000..a3f4d22 --- /dev/null +++ b/apps/patient/test/contrast_tokens_test.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_patient/app/patient_theme.dart'; + +import 'support/contrast.dart'; + +/// Matriz determinística de contraste (WCAG 2.1 §1.4.3) — ver o irmão em +/// `apps/acs/test/contrast_tokens_test.dart` para o porquê deste teste +/// existir no lugar da medição manual no WebAIM. +void main() { + const normalText = 4.5; + const largeTextOrUi = 3.0; + + const cases = <(String, Color, Color, double)>[ + ('branco sobre scaffold', Colors.white, PatientColors.background, normalText), + ('branco sobre card', Colors.white, PatientColors.surfaceRaised, normalText), + // Achado A do relatório antigo era falso positivo: passa com folga. + ('white54 sobre card (rodapé da triagem)', Colors.white54, PatientColors.surfaceRaised, normalText), + ('yellow #E0A800 sobre card (risco amarelo)', Color(0xFFE0A800), PatientColors.surfaceRaised, normalText), + // Variantes de texto que o app usa em vez do fill puro (ver os testes de + // documentação abaixo para a prova de que o fill sozinho falha). + ('dangerOnSurface sobre card', PatientColors.dangerOnSurface, PatientColors.surfaceRaised, normalText), + ('dangerOnSurface sobre scaffold', PatientColors.dangerOnSurface, PatientColors.background, normalText), + ('accentOnSurface sobre card', PatientColors.accentOnSurface, PatientColors.surfaceRaised, normalText), + // Preenchimento de botão: continua correto sem token novo. + ('branco sobre botão de pânico (danger fill)', Colors.white, PatientColors.danger, largeTextOrUi), + ]; + + for (final (label, fg, bg, threshold) in cases) { + test(label, () { + final ratio = contrastOn(fg, bg); + expect( + ratio, + greaterThanOrEqualTo(threshold), + reason: '$label: $ratio:1 abaixo do limiar $threshold:1 exigido pela WCAG 1.4.3', + ); + }); + } + + // Achado A (refutado) e os achados de texto do relatório reescrito: os + // TOKENS DE PREENCHIMENTO, usados como texto direto sobre superfície, + // falham — por isso `dangerOnSurface`/`accentOnSurface` existem em vez de + // reaproveitar `danger`/`accent` também para texto. + test('danger (fill) usado como texto sobre card fica abaixo de 4.5:1', () { + expect(contrastOn(PatientColors.danger, PatientColors.surfaceRaised), lessThan(normalText)); + }); + + test('danger (fill) usado como texto sobre scaffold fica abaixo de 4.5:1', () { + expect(contrastOn(PatientColors.danger, PatientColors.background), lessThan(normalText)); + }); + + test('accent (fill) usado como texto sobre card fica abaixo de 4.5:1', () { + expect(contrastOn(PatientColors.accent, PatientColors.surfaceRaised), lessThan(normalText)); + }); +} diff --git a/apps/patient/test/encrypted_database_test.dart b/apps/patient/test/encrypted_database_test.dart deleted file mode 100644 index 57a1715..0000000 --- a/apps/patient/test/encrypted_database_test.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:sinalacs_patient/core/database/encrypted_database.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - test('deve abrir banco criptografado com senha configurada', () async { - final db = await EncryptedLocalDatabase.open( - databaseName: 'sinalacs_patient_test.db', - passphrase: 'test-passphrase', - ); - - expect(db.isOpen, isTrue); - await db.execute('CREATE TABLE IF NOT EXISTS test_table (id INTEGER PRIMARY KEY)'); - await db.close(); - }); -} diff --git a/apps/patient/test/patient_app_mvp_test.dart b/apps/patient/test/patient_app_mvp_test.dart index a08a744..cde3713 100644 --- a/apps/patient/test/patient_app_mvp_test.dart +++ b/apps/patient/test/patient_app_mvp_test.dart @@ -1,39 +1,146 @@ + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show RiskLevel; import 'package:sinalacs_patient/app/app.dart'; +import 'package:sinalacs_patient/core/network/backend_client.dart'; + +import 'support/fake_patient_backend.dart'; + +/// Responde "não" a todos os sintomas menos [yesTo], e conclui a triagem. +Future answerTriage( + WidgetTester tester, { + Set yesTo = const {}, +}) async { + for (final symptom in TriageSymptom.values) { + final key = yesTo.contains(symptom) ? symptom.key : '${symptom.key}_no'; + await tester.tap(find.byKey(Key(key))); + await tester.pump(); + await tester.tap(find.byKey(const Key('submit_triage'))); + await tester.pumpAndSettle(); + } +} + +Future login(WidgetTester tester) async { + await tester.tap(find.byKey(const Key('enter_button'))); + await tester.pumpAndSettle(); +} void main() { - testWidgets('deve abrir a triagem do paciente e classificar risco', (tester) async { - await tester.pumpWidget(const SinalAcsApp()); + testWidgets('deve autenticar no backend antes de abrir a triagem', (tester) async { + final backend = FakePatientBackend(); + await tester.pumpWidget(SinalAcsApp(backend: backend)); expect(find.text('SinalACS'), findsOneWidget); expect(find.text('Acesso sem senha'), findsOneWidget); - await tester.tap(find.byKey(const Key('enter_button'))); - await tester.pumpAndSettle(); + await login(tester); + expect(backend.loginCount, 1); expect(find.text('Triagem rápida'), findsOneWidget); + }); - await tester.tap(find.byKey(const Key('difficulty_breathing'))); - await tester.pump(); - await tester.tap(find.byKey(const Key('submit_triage'))); - await tester.pumpAndSettle(); + testWidgets('não deve avançar quando a autenticação falha', (tester) async { + final handle = tester.ensureSemantics(); + final backend = FakePatientBackend( + loginFailure: const BackendFailure('Sem conexão com o servidor.'), + ); + await tester.pumpWidget(SinalAcsApp(backend: backend)); - await tester.tap(find.byKey(const Key('triage_1_0'))); - await tester.pump(); - await tester.tap(find.byKey(const Key('submit_triage'))); + await login(tester); + + // A tela antiga navegava incondicionalmente; a regressão que este teste + // protege é justamente entrar no app sem ter falado com o servidor. + expect(find.text('Triagem rápida'), findsNothing); + expect(find.byKey(const Key('login_error')), findsOneWidget); + expect(find.text('Sem conexão com o servidor.'), findsOneWidget); + // SC 4.1.3: o erro aparece sem mover o foco — sem `liveRegion` um leitor + // de tela nunca saberia que o login falhou. + final semantics = tester.getSemantics(find.byKey(const Key('login_error'))); + expect(semantics.flagsCollection.isLiveRegion, isTrue); + handle.dispose(); + }); + + testWidgets('deve enviar os sintomas ao servidor e exibir o risco recebido', (tester) async { + final backend = FakePatientBackend(risk: RiskLevel.red); + await tester.pumpWidget(SinalAcsApp(backend: backend)); + + await login(tester); + await answerTriage(tester, yesTo: {TriageSymptom.chestPain}); + + expect(backend.triageCalls, hasLength(1)); + expect(backend.triageCalls.single, { + 'chestPain': true, + 'difficultyBreathing': false, + 'fever': false, + 'persistentVomiting': false, + 'bleeding': false, + 'severeWeakness': false, + }); + expect(find.text('Risco: Vermelho'), findsOneWidget); + }); + + testWidgets('deve exibir o risco do servidor mesmo quando contraria o sintoma informado', (tester) async { + // O app não tem regra de risco própria: exibe o que o motor determinístico + // do servidor devolveu. Se a tela recalculasse localmente, este teste + // mostraria "Vermelho" e falharia. + final backend = FakePatientBackend(risk: RiskLevel.green); + await tester.pumpWidget(SinalAcsApp(backend: backend)); + + await login(tester); + await answerTriage(tester, yesTo: {TriageSymptom.chestPain}); + + expect(find.text('Risco: Verde'), findsOneWidget); + expect(find.text('Risco: Vermelho'), findsNothing); + }); + + testWidgets('deve reusar a chave de idempotência quando o envio do alerta falha', (tester) async { + final backend = FakePatientBackend( + alertFailure: const BackendFailure('Sem conexão com o servidor.'), + ); + await tester.pumpWidget(SinalAcsApp(backend: backend)); + + await login(tester); + + // Vai para a aba de urgência. + await tester.tap(find.text('Urgência')); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('chest_pain'))); - await tester.pump(); - await tester.tap(find.byKey(const Key('submit_triage'))); + for (var attempt = 0; attempt < 2; attempt++) { + await tester.tap(find.byKey(const Key('panic_button'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Confirmar alerta')); + await tester.pumpAndSettle(); + } + + expect(backend.idempotencyKeys, hasLength(2)); + // Duas tentativas da MESMA emergência precisam levar a mesma chave, senão o + // retry cria um segundo alerta vermelho no servidor. + expect(backend.idempotencyKeys.first, backend.idempotencyKeys.last); + }); + + testWidgets('o estado do alerta de emergência é anunciado ao leitor de tela', (tester) async { + // É a confirmação de que o alerta chegou à equipe — o ponto mais crítico + // do app para um leitor de tela anunciar sem depender de a pessoa + // varrer a tela de novo. + final handle = tester.ensureSemantics(); + await tester.pumpWidget(SinalAcsApp(backend: FakePatientBackend())); + + await login(tester); + await tester.tap(find.text('Urgência')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('panic_button'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Confirmar alerta')); await tester.pumpAndSettle(); - expect(find.text('Solicitação de visita #4082'), findsOneWidget); + final semantics = tester.getSemantics(find.text('Alerta recebido pela equipe')); + expect(semantics.flagsCollection.isLiveRegion, isTrue); + handle.dispose(); }); testWidgets('deve expor rótulo semântico e alvo de toque acessível no fluxo do paciente', (tester) async { - await tester.pumpWidget(const SinalAcsApp()); + await tester.pumpWidget(SinalAcsApp(backend: FakePatientBackend())); final enterButton = tester.widget(find.byKey(const Key('enter_button'))); final minimumSize = enterButton.style?.minimumSize?.resolve({}) ?? const Size(0, 0); @@ -42,4 +149,16 @@ void main() { expect(minimumSize.height, greaterThanOrEqualTo(48)); expect(minimumSize.width, greaterThanOrEqualTo(48)); }); + + testWidgets('a tela de login atende às diretrizes de contraste e alvo de toque do Flutter', (tester) async { + // Substitui a auditoria manual no WebAIM/TalkBack do relatório anterior + // por uma verificação determinística que o CI roda sozinho. + final handle = tester.ensureSemantics(); + await tester.pumpWidget(SinalAcsApp(backend: FakePatientBackend())); + + await expectLater(tester, meetsGuideline(textContrastGuideline)); + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + handle.dispose(); + }); } diff --git a/apps/patient/test/support/contrast.dart b/apps/patient/test/support/contrast.dart new file mode 100644 index 0000000..ea3c8ed --- /dev/null +++ b/apps/patient/test/support/contrast.dart @@ -0,0 +1,40 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +/// Luminância relativa WCAG (definição de 1.4.3), 0.0 (preto) a 1.0 (branco). +double relativeLuminance(Color color) { + double channel(double srgb) => + srgb <= 0.04045 ? srgb / 12.92 : math.pow((srgb + 0.055) / 1.055, 2.4).toDouble(); + // `Color.r/g/b` (0.0-1.0) substitui `.red/.green/.blue` (0-255), + // descontinuados nesta versão do Flutter. + return 0.2126 * channel(color.r) + 0.7152 * channel(color.g) + 0.0722 * channel(color.b); +} + +/// Razão de contraste WCAG entre duas cores OPACAS (sem canal alfa a +/// resolver). Para uma cor com alfa, componha antes com [compositeOver]. +double contrastRatio(Color a, Color b) { + final la = relativeLuminance(a); + final lb = relativeLuminance(b); + final hi = la > lb ? la : lb; + final lo = la > lb ? lb : la; + return (hi + 0.05) / (lo + 0.05); +} + +/// Resolve uma cor com transparência (`Colors.white54`, +/// `color.withValues(alpha: ...)`) contra o fundo em que ela realmente é +/// pintada — sem isto, a luminância de uma cor semitransparente não +/// corresponde ao que a tela mostra. +Color compositeOver(Color fg, Color bg) { + final a = fg.a; // 0.0-1.0 + double mix(double f, double b) => a * f + (1 - a) * b; + return Color.from( + alpha: 1.0, + red: mix(fg.r, bg.r), + green: mix(fg.g, bg.g), + blue: mix(fg.b, bg.b), + ); +} + +/// Razão de contraste já resolvendo o alfa de [fg] contra [bg]. +double contrastOn(Color fg, Color bg) => contrastRatio(compositeOver(fg, bg), bg); diff --git a/apps/patient/test/support/fake_patient_backend.dart b/apps/patient/test/support/fake_patient_backend.dart new file mode 100644 index 0000000..c13ce45 --- /dev/null +++ b/apps/patient/test/support/fake_patient_backend.dart @@ -0,0 +1,100 @@ +import 'package:sinalacs_client/sinalacs_client.dart'; +import 'package:sinalacs_patient/core/network/auth_session.dart'; +import 'package:sinalacs_patient/core/network/backend_client.dart'; + +/// Duplo de teste do backend do paciente. +/// +/// Mantém os testes de widget herméticos: eles verificam o comportamento da +/// tela (o que é enviado, o que é exibido), não a rede. A conexão real é +/// verificada pelos testes de integração e por `tool/live_check.dart`. +class FakePatientBackend implements PatientBackend { + FakePatientBackend({ + this.risk = RiskLevel.green, + this.loginFailure, + this.alertFailure, + }); + + /// Risco que o "servidor" devolve. A tela não pode derivá-lo por conta própria. + RiskLevel risk; + + BackendFailure? loginFailure; + BackendFailure? alertFailure; + + /// Argumentos recebidos, para as asserções. + final List> triageCalls = >[]; + final List idempotencyKeys = []; + int loginCount = 0; + bool closed = false; + + AuthSession? _session; + + @override + AuthSession? get session => _session; + + @override + bool get isAuthenticated => _session != null; + + @override + Future health() async => ServiceHealth( + status: 'ok', + mqttConnected: true, + dbConnected: true, + ); + + @override + Future login() async { + loginCount++; + final failure = loginFailure; + if (failure != null) throw failure; + + final session = AuthSession( + accessToken: 'token-de-teste', + tokenType: 'Bearer', + userId: '00000000-0000-4000-8000-000000000001', + role: 'patient', + microAreaId: '00000000-0000-4000-8000-000000000003', + expiresAt: DateTime.now().toUtc().add(const Duration(minutes: 15)), + ); + _session = session; + return session; + } + + @override + Future evaluateTriage({ + required bool chestPain, + required bool difficultyBreathing, + required bool fever, + required bool persistentVomiting, + required bool bleeding, + required bool severeWeakness, + }) async { + triageCalls.add({ + 'chestPain': chestPain, + 'difficultyBreathing': difficultyBreathing, + 'fever': fever, + 'persistentVomiting': persistentVomiting, + 'bleeding': bleeding, + 'severeWeakness': severeWeakness, + }); + return risk; + } + + @override + Future createRedAlert({ + required String idempotencyKey, + required String locationHash, + }) async { + idempotencyKeys.add(idempotencyKey); + final failure = alertFailure; + if (failure != null) throw failure; + + return RedAlertResult( + alertId: 'alerta-de-teste', + status: AlertStatus.pending, + published: true, + ); + } + + @override + void close() => closed = true; +} diff --git a/apps/patient/tool/live_check.dart b/apps/patient/tool/live_check.dart new file mode 100644 index 0000000..a4ca580 --- /dev/null +++ b/apps/patient/tool/live_check.dart @@ -0,0 +1,86 @@ +/// Verificação rápida da camada de rede do app contra a stack local. +/// +/// Roda na VM, sem emulador, e usa o MESMO `BackendClient` que a UI usa — é o +/// que separa "o backend está de pé" (video/rpc_demo) de "o app fala com ele". +/// +/// Pré-requisito: `docker compose up` com o database-seed concluído. +/// +/// cd apps/patient +/// dart run tool/live_check.dart +/// dart run tool/live_check.dart --host http://10.0.2.2:8080/ +library; + +import 'dart:io'; + +import 'package:sinalacs_patient/core/network/backend_client.dart'; +import 'package:sinalacs_patient/core/privacy/location_hash.dart'; + +Future main(List args) async { + final hostIndex = args.indexOf('--host'); + final host = hostIndex >= 0 && hostIndex + 1 < args.length + ? args[hostIndex + 1] + : 'http://localhost:8080/'; + + final backend = BackendClient(host: host); + stdout.writeln('paciente → $host'); + + try { + final health = await backend.health(); + stdout.writeln(' health ............. ${health.status} ' + '(db=${health.dbConnected} mqtt=${health.mqttConnected})'); + + final session = await backend.login(); + // O token nunca é impresso inteiro. + stdout.writeln(' login .............. papel=${session.role} ' + 'microárea=${session.microAreaId} expira=${session.expiresAt.toIso8601String()}'); + + final red = await backend.evaluateTriage( + chestPain: true, + difficultyBreathing: false, + fever: false, + persistentVomiting: false, + bleeding: false, + severeWeakness: false, + ); + final green = await backend.evaluateTriage( + chestPain: false, + difficultyBreathing: false, + fever: false, + persistentVomiting: false, + bleeding: false, + severeWeakness: false, + ); + stdout.writeln(' triagem ............ dor no peito=${red.name} ' + 'sem sintomas=${green.name}'); + if (red.name != 'red' || green.name != 'green') { + stderr.writeln(' ERRO: motor de triagem do servidor respondeu fora do esperado.'); + exitCode = 1; + } + + final key = 'live-check-${DateTime.now().millisecondsSinceEpoch}'; + final hash = locationHashFrom(-23.55052, -46.633308); + final first = await backend.createRedAlert( + idempotencyKey: key, + locationHash: hash, + ); + final again = await backend.createRedAlert( + idempotencyKey: key, + locationHash: hash, + ); + stdout.writeln(' alerta vermelho .... ${first.alertId} ' + '(publicado=${first.published})'); + if (first.alertId == again.alertId) { + stdout.writeln(' idempotência ....... mesmo alertId no reenvio'); + } else { + stderr.writeln(' ERRO: reenvio com a mesma chave criou outro alerta.'); + exitCode = 1; + } + + stdout.writeln(exitCode == 0 ? '\nOK — o app fala com o backend.' : '\nFALHOU.'); + } on BackendFailure catch (failure) { + stderr.writeln(' falhou: ${failure.message}'); + exitCode = 1; + } finally { + backend.close(); + } +} diff --git a/backend/DEPLOY.md b/backend/DEPLOY.md index fe9293a..d6843a8 100644 --- a/backend/DEPLOY.md +++ b/backend/DEPLOY.md @@ -88,6 +88,17 @@ Configurar como variáveis de ambiente secretas (nunca commitadas): | `APP_ENV` | `production` | | `ENABLE_DEV_LOGIN` | `true` (decisão consciente — é o único mecanismo de auth do piloto) | +> **Sobre os valores de desenvolvimento.** As senhas de desenvolvimento do +> repositório foram rotacionadas: `.env` passou a ser gerado por máquina com +> `scripts/dev/bootstrap_env.sh`, e os literais que antes estavam no +> `docker-compose.yml`, no `ci.yml` e no compose do Serverpod foram removidos. +> Os valores antigos continuam visíveis no histórico do git (que não foi +> reescrito) e **não valem mais** em lugar nenhum — não os reaproveite. +> +> `JWT_SECRET` agora é obrigatório fora de `development`: o servidor recusa subir +> com o valor ausente, vazio ou igual ao fallback de desenvolvimento, em vez de +> assinar tokens com uma chave pública. + Não setar `MQTT_CA_CERT_PATH` — o HiveMQ Cloud usa certificado de CA pública, e o cliente MQTT confia nas CAs padrão do sistema quando essa variável não é definida. diff --git a/backend/sinalacs_client/lib/src/protocol/api/micro_area_patient.dart b/backend/sinalacs_client/lib/src/protocol/api/micro_area_patient.dart new file mode 100644 index 0000000..752294b --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/api/micro_area_patient.dart @@ -0,0 +1,115 @@ +/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */ +/* To generate run: "serverpod generate" */ + +// ignore_for_file: implementation_imports +// ignore_for_file: library_private_types_in_public_api +// ignore_for_file: non_constant_identifier_names +// ignore_for_file: public_member_api_docs +// ignore_for_file: type_literal_in_constant_pattern +// ignore_for_file: use_super_parameters +// ignore_for_file: invalid_use_of_internal_member + +// ignore_for_file: no_leading_underscores_for_library_prefixes + +import 'package:serverpod_client/serverpod_client.dart' as _i1; +import 'package:sinalacs_client/src/protocol/protocol.dart' as _i2; + +/// Um paciente da microárea do ACS, para escolher em quem registrar uma visita +/// de rotina. +/// +/// Minimização (LGPD §5.6, spec/lgpd_design.md:364): só o que uma visita de +/// rotina precisa para escolher o paciente certo — nome e condições crônicas. +/// `emergencyContact` fica de fora de propósito: não ajuda a escolher quem +/// visitar. Não existe campo de endereço porque `Patient` não tem essa coluna. +abstract class MicroAreaPatient implements _i1.SerializableModel { + MicroAreaPatient._({ + required this.patientId, + required this.name, + required this.isChronic, + required this.chronicConditions, + }); + + factory MicroAreaPatient({ + required String patientId, + required String name, + required bool isChronic, + required List chronicConditions, + }) = _MicroAreaPatientImpl; + + factory MicroAreaPatient.fromJson(Map jsonSerialization) { + return MicroAreaPatient( + patientId: jsonSerialization['patientId'] as String, + name: jsonSerialization['name'] as String, + isChronic: _i1.BoolJsonExtension.fromJson(jsonSerialization['isChronic']), + chronicConditions: _i2.Protocol().deserialize>( + jsonSerialization['chronicConditions'], + ), + ); + } + + String patientId; + + String name; + + bool isChronic; + + List chronicConditions; + + /// Returns a shallow copy of this [MicroAreaPatient] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + MicroAreaPatient copyWith({ + String? patientId, + String? name, + bool? isChronic, + List? chronicConditions, + }); + @override + Map toJson() { + return { + '__className__': 'MicroAreaPatient', + 'patientId': patientId, + 'name': name, + 'isChronic': isChronic, + 'chronicConditions': chronicConditions.toJson(), + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _MicroAreaPatientImpl extends MicroAreaPatient { + _MicroAreaPatientImpl({ + required String patientId, + required String name, + required bool isChronic, + required List chronicConditions, + }) : super._( + patientId: patientId, + name: name, + isChronic: isChronic, + chronicConditions: chronicConditions, + ); + + /// Returns a shallow copy of this [MicroAreaPatient] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + MicroAreaPatient copyWith({ + String? patientId, + String? name, + bool? isChronic, + List? chronicConditions, + }) { + return MicroAreaPatient( + patientId: patientId ?? this.patientId, + name: name ?? this.name, + isChronic: isChronic ?? this.isChronic, + chronicConditions: + chronicConditions ?? this.chronicConditions.map((e0) => e0).toList(), + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/api/visit_sync_entry.dart b/backend/sinalacs_client/lib/src/protocol/api/visit_sync_entry.dart new file mode 100644 index 0000000..d44307d --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/api/visit_sync_entry.dart @@ -0,0 +1,196 @@ +/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */ +/* To generate run: "serverpod generate" */ + +// ignore_for_file: implementation_imports +// ignore_for_file: library_private_types_in_public_api +// ignore_for_file: non_constant_identifier_names +// ignore_for_file: public_member_api_docs +// ignore_for_file: type_literal_in_constant_pattern +// ignore_for_file: use_super_parameters +// ignore_for_file: invalid_use_of_internal_member + +// ignore_for_file: no_leading_underscores_for_library_prefixes + +import 'package:serverpod_client/serverpod_client.dart' as _i1; +import '../enums/risk_level.dart' as _i2; +import 'package:sinalacs_client/src/protocol/protocol.dart' as _i3; + +/// Uma visita registrada offline, enviada pelo app do ACS para sincronização. +/// +/// `localId` é gerado no dispositivo e tem índice único em `visits`: é o que +/// permite reenviar o mesmo lote depois de uma falha de rede sem duplicar a +/// visita. `version` é a versão que o dispositivo conhece — divergir da versão +/// do servidor significa que alguém alterou a visita no meio, e o resultado é +/// conflito, não sobrescrita. +abstract class VisitSyncEntry implements _i1.SerializableModel { + VisitSyncEntry._({ + required this.localId, + required this.patientId, + required this.scheduledAt, + this.completedAt, + required this.status, + required this.riskLevelBefore, + this.riskLevelAfter, + required this.notes, + required this.version, + }); + + factory VisitSyncEntry({ + required String localId, + required String patientId, + required DateTime scheduledAt, + DateTime? completedAt, + required String status, + required _i2.RiskLevel riskLevelBefore, + _i2.RiskLevel? riskLevelAfter, + required Map notes, + required int version, + }) = _VisitSyncEntryImpl; + + factory VisitSyncEntry.fromJson(Map jsonSerialization) { + return VisitSyncEntry( + localId: jsonSerialization['localId'] as String, + patientId: jsonSerialization['patientId'] as String, + scheduledAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['scheduledAt'], + ), + completedAt: jsonSerialization['completedAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['completedAt'], + ), + status: jsonSerialization['status'] as String, + riskLevelBefore: _i2.RiskLevel.fromJson( + (jsonSerialization['riskLevelBefore'] as String), + ), + riskLevelAfter: jsonSerialization['riskLevelAfter'] == null + ? null + : _i2.RiskLevel.fromJson( + (jsonSerialization['riskLevelAfter'] as String), + ), + notes: _i3.Protocol().deserialize>( + jsonSerialization['notes'], + ), + version: jsonSerialization['version'] as int, + ); + } + + String localId; + + String patientId; + + DateTime scheduledAt; + + DateTime? completedAt; + + String status; + + _i2.RiskLevel riskLevelBefore; + + _i2.RiskLevel? riskLevelAfter; + + Map notes; + + int version; + + /// Returns a shallow copy of this [VisitSyncEntry] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + VisitSyncEntry copyWith({ + String? localId, + String? patientId, + DateTime? scheduledAt, + DateTime? completedAt, + String? status, + _i2.RiskLevel? riskLevelBefore, + _i2.RiskLevel? riskLevelAfter, + Map? notes, + int? version, + }); + @override + Map toJson() { + return { + '__className__': 'VisitSyncEntry', + 'localId': localId, + 'patientId': patientId, + 'scheduledAt': scheduledAt.toJson(), + if (completedAt != null) 'completedAt': completedAt?.toJson(), + 'status': status, + 'riskLevelBefore': riskLevelBefore.toJson(), + if (riskLevelAfter != null) 'riskLevelAfter': riskLevelAfter?.toJson(), + 'notes': notes.toJson(), + 'version': version, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _VisitSyncEntryImpl extends VisitSyncEntry { + _VisitSyncEntryImpl({ + required String localId, + required String patientId, + required DateTime scheduledAt, + DateTime? completedAt, + required String status, + required _i2.RiskLevel riskLevelBefore, + _i2.RiskLevel? riskLevelAfter, + required Map notes, + required int version, + }) : super._( + localId: localId, + patientId: patientId, + scheduledAt: scheduledAt, + completedAt: completedAt, + status: status, + riskLevelBefore: riskLevelBefore, + riskLevelAfter: riskLevelAfter, + notes: notes, + version: version, + ); + + /// Returns a shallow copy of this [VisitSyncEntry] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + VisitSyncEntry copyWith({ + String? localId, + String? patientId, + DateTime? scheduledAt, + Object? completedAt = _Undefined, + String? status, + _i2.RiskLevel? riskLevelBefore, + Object? riskLevelAfter = _Undefined, + Map? notes, + int? version, + }) { + return VisitSyncEntry( + localId: localId ?? this.localId, + patientId: patientId ?? this.patientId, + scheduledAt: scheduledAt ?? this.scheduledAt, + completedAt: completedAt is DateTime? ? completedAt : this.completedAt, + status: status ?? this.status, + riskLevelBefore: riskLevelBefore ?? this.riskLevelBefore, + riskLevelAfter: riskLevelAfter is _i2.RiskLevel? + ? riskLevelAfter + : this.riskLevelAfter, + notes: + notes ?? + this.notes.map( + ( + key0, + value0, + ) => MapEntry( + key0, + value0, + ), + ), + version: version ?? this.version, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/api/visit_sync_result.dart b/backend/sinalacs_client/lib/src/protocol/api/visit_sync_result.dart new file mode 100644 index 0000000..2e09a2d --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/api/visit_sync_result.dart @@ -0,0 +1,117 @@ +/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */ +/* To generate run: "serverpod generate" */ + +// ignore_for_file: implementation_imports +// ignore_for_file: library_private_types_in_public_api +// ignore_for_file: non_constant_identifier_names +// ignore_for_file: public_member_api_docs +// ignore_for_file: type_literal_in_constant_pattern +// ignore_for_file: use_super_parameters +// ignore_for_file: invalid_use_of_internal_member + +// ignore_for_file: no_leading_underscores_for_library_prefixes + +import 'package:serverpod_client/serverpod_client.dart' as _i1; +import '../enums/sync_status.dart' as _i2; + +/// Resultado da sincronização de UMA visita. +/// +/// O app casa pelo `localId`, não pela posição na lista: um resultado ausente +/// deixa a visita pendente para a próxima tentativa, em vez de dá-la por +/// sincronizada. +abstract class VisitSyncResult implements _i1.SerializableModel { + VisitSyncResult._({ + required this.localId, + required this.syncStatus, + this.serverVersion, + this.message, + }); + + factory VisitSyncResult({ + required String localId, + required _i2.SyncStatus syncStatus, + int? serverVersion, + String? message, + }) = _VisitSyncResultImpl; + + factory VisitSyncResult.fromJson(Map jsonSerialization) { + return VisitSyncResult( + localId: jsonSerialization['localId'] as String, + syncStatus: _i2.SyncStatus.fromJson( + (jsonSerialization['syncStatus'] as String), + ), + serverVersion: jsonSerialization['serverVersion'] as int?, + message: jsonSerialization['message'] as String?, + ); + } + + String localId; + + _i2.SyncStatus syncStatus; + + /// Versão gravada no servidor. Em conflito, é a versão que o dispositivo + /// precisa reconciliar antes de reenviar. + int? serverVersion; + + /// Preenchido apenas quando syncStatus é error. + String? message; + + /// Returns a shallow copy of this [VisitSyncResult] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + VisitSyncResult copyWith({ + String? localId, + _i2.SyncStatus? syncStatus, + int? serverVersion, + String? message, + }); + @override + Map toJson() { + return { + '__className__': 'VisitSyncResult', + 'localId': localId, + 'syncStatus': syncStatus.toJson(), + if (serverVersion != null) 'serverVersion': serverVersion, + if (message != null) 'message': message, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _VisitSyncResultImpl extends VisitSyncResult { + _VisitSyncResultImpl({ + required String localId, + required _i2.SyncStatus syncStatus, + int? serverVersion, + String? message, + }) : super._( + localId: localId, + syncStatus: syncStatus, + serverVersion: serverVersion, + message: message, + ); + + /// Returns a shallow copy of this [VisitSyncResult] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + VisitSyncResult copyWith({ + String? localId, + _i2.SyncStatus? syncStatus, + Object? serverVersion = _Undefined, + Object? message = _Undefined, + }) { + return VisitSyncResult( + localId: localId ?? this.localId, + syncStatus: syncStatus ?? this.syncStatus, + serverVersion: serverVersion is int? ? serverVersion : this.serverVersion, + message: message is String? ? message : this.message, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/audit_log.dart b/backend/sinalacs_client/lib/src/protocol/audit_log.dart index 4a20a73..902ab71 100644 --- a/backend/sinalacs_client/lib/src/protocol/audit_log.dart +++ b/backend/sinalacs_client/lib/src/protocol/audit_log.dart @@ -14,6 +14,16 @@ import 'package:serverpod_client/serverpod_client.dart' as _i1; /// Trilha de auditoria de acesso a dados sensíveis. Append-only. +/// +/// `sequence`/`previousHash`/`entryHash` formam uma cadeia de hash (LGPD-RT03): +/// cada linha encadeia à anterior via `previousHash == entryHash` da linha de +/// `sequence - 1`, e `entryHash` é um HMAC-SHA256 sobre o conteúdo da própria +/// linha (ver `application/audit/audit_chain.dart`). Isso torna qualquer +/// edição, remoção ou reordenação de linha detectável — inclusive por quem tem +/// acesso de escrita direto ao Postgres, que é o adversário que a §458 de +/// spec/lgpd_design.md descreve. O índice único em `sequence` é o segundo +/// cinto: uma bifurcação da cadeia por concorrência estoura na hora em vez de +/// corromper em silêncio. abstract class AuditLog implements _i1.SerializableModel { AuditLog._({ this.id, @@ -24,6 +34,9 @@ abstract class AuditLog implements _i1.SerializableModel { required this.timestamp, required this.ipHash, required this.result, + required this.sequence, + required this.previousHash, + required this.entryHash, }); factory AuditLog({ @@ -35,6 +48,9 @@ abstract class AuditLog implements _i1.SerializableModel { required DateTime timestamp, required String ipHash, required String result, + required int sequence, + required String previousHash, + required String entryHash, }) = _AuditLogImpl; factory AuditLog.fromJson(Map jsonSerialization) { @@ -55,6 +71,9 @@ abstract class AuditLog implements _i1.SerializableModel { ), ipHash: jsonSerialization['ipHash'] as String, result: jsonSerialization['result'] as String, + sequence: jsonSerialization['sequence'] as int, + previousHash: jsonSerialization['previousHash'] as String, + entryHash: jsonSerialization['entryHash'] as String, ); } @@ -77,6 +96,18 @@ abstract class AuditLog implements _i1.SerializableModel { String result; + /// Posição na cadeia, começando em 1. Contígua por construção — um buraco + /// aqui é uma linha apagada. + int sequence; + + /// `entryHash` da linha anterior, ou `AuditChain.genesisHash` (64 zeros) na + /// primeira linha. Nunca nulo: gênese e "escrito antes da cadeia existir" + /// não podem ter a mesma representação. + String previousHash; + + /// HMAC-SHA256(AUDIT_CHAIN_SECRET, conteúdo da linha). Ver AuditChain.compute. + String entryHash; + /// Returns a shallow copy of this [AuditLog] /// with some or all fields replaced by the given arguments. @_i1.useResult @@ -89,6 +120,9 @@ abstract class AuditLog implements _i1.SerializableModel { DateTime? timestamp, String? ipHash, String? result, + int? sequence, + String? previousHash, + String? entryHash, }); @override Map toJson() { @@ -102,6 +136,9 @@ abstract class AuditLog implements _i1.SerializableModel { 'timestamp': timestamp.toJson(), 'ipHash': ipHash, 'result': result, + 'sequence': sequence, + 'previousHash': previousHash, + 'entryHash': entryHash, }; } @@ -123,6 +160,9 @@ class _AuditLogImpl extends AuditLog { required DateTime timestamp, required String ipHash, required String result, + required int sequence, + required String previousHash, + required String entryHash, }) : super._( id: id, userId: userId, @@ -132,6 +172,9 @@ class _AuditLogImpl extends AuditLog { timestamp: timestamp, ipHash: ipHash, result: result, + sequence: sequence, + previousHash: previousHash, + entryHash: entryHash, ); /// Returns a shallow copy of this [AuditLog] @@ -147,6 +190,9 @@ class _AuditLogImpl extends AuditLog { DateTime? timestamp, String? ipHash, String? result, + int? sequence, + String? previousHash, + String? entryHash, }) { return AuditLog( id: id is _i1.UuidValue? ? id : this.id, @@ -157,6 +203,9 @@ class _AuditLogImpl extends AuditLog { timestamp: timestamp ?? this.timestamp, ipHash: ipHash ?? this.ipHash, result: result ?? this.result, + sequence: sequence ?? this.sequence, + previousHash: previousHash ?? this.previousHash, + entryHash: entryHash ?? this.entryHash, ); } } diff --git a/backend/sinalacs_client/lib/src/protocol/client.dart b/backend/sinalacs_client/lib/src/protocol/client.dart index c359a41..7baaf10 100644 --- a/backend/sinalacs_client/lib/src/protocol/client.dart +++ b/backend/sinalacs_client/lib/src/protocol/client.dart @@ -18,8 +18,12 @@ import 'package:sinalacs_client/src/protocol/api/alert_ack_result.dart' as _i4; import 'package:sinalacs_client/src/protocol/api/development_login_result.dart' as _i5; import 'package:sinalacs_client/src/protocol/api/service_health.dart' as _i6; -import 'package:sinalacs_client/src/protocol/api/triage_result.dart' as _i7; -import 'protocol.dart' as _i8; +import 'package:sinalacs_client/src/protocol/api/micro_area_patient.dart' + as _i7; +import 'package:sinalacs_client/src/protocol/api/triage_result.dart' as _i8; +import 'package:sinalacs_client/src/protocol/api/visit_sync_result.dart' as _i9; +import 'package:sinalacs_client/src/protocol/api/visit_sync_entry.dart' as _i10; +import 'protocol.dart' as _i11; /// Ciclo do alerta vermelho. /// @@ -115,6 +119,28 @@ class EndpointHealth extends _i1.EndpointRef { ); } +/// Diretório de pacientes da microárea do ACS. +/// +/// Existe para a visita de rotina: o único produtor de alertas +/// (`alerts.createRedAlert`) publica só `riskLevel: 'red'` — emergência com +/// SAMU —, e sem esta lista não havia como o ACS escolher um paciente para +/// visitar fora do caminho reativo. +/// {@category Endpoint} +class EndpointPatients extends _i1.EndpointRef { + EndpointPatients(_i1.EndpointCaller caller) : super(caller); + + @override + String get name => 'patients'; + + _i2.Future> listMicroArea({ + required String accessToken, + }) => caller.callServerEndpoint>( + 'patients', + 'listMicroArea', + {'accessToken': accessToken}, + ); +} + /// Motor de triagem determinístico, inspirado no Protocolo de Manchester. /// /// Endpoint novo: o [TriageEngine] já existia e era testado, mas nunca esteve @@ -131,14 +157,14 @@ class EndpointTriage extends _i1.EndpointRef { @override String get name => 'triage'; - _i2.Future<_i7.TriageResult> evaluate({ + _i2.Future<_i8.TriageResult> evaluate({ required bool chestPain, required bool difficultyBreathing, required bool fever, required bool persistentVomiting, required bool bleeding, required bool severeWeakness, - }) => caller.callServerEndpoint<_i7.TriageResult>( + }) => caller.callServerEndpoint<_i8.TriageResult>( 'triage', 'evaluate', { @@ -152,6 +178,35 @@ class EndpointTriage extends _i1.EndpointRef { ); } +/// Sincronização das visitas domiciliares registradas offline. +/// +/// É a contraparte da fila offline do app do ACS: o dispositivo grava a visita +/// localmente durante a visita (onde normalmente não há rede) e envia o lote +/// quando a conexão volta. +/// +/// O lote inteiro roda em uma transação: ou todas as visitas são aplicadas, ou +/// nenhuma. Um resultado parcial deixaria o dispositivo sem saber o que +/// reenviar. +/// {@category Endpoint} +class EndpointVisits extends _i1.EndpointRef { + EndpointVisits(_i1.EndpointCaller caller) : super(caller); + + @override + String get name => 'visits'; + + _i2.Future> sync({ + required String accessToken, + required List<_i10.VisitSyncEntry> visits, + }) => caller.callServerEndpoint>( + 'visits', + 'sync', + { + 'accessToken': accessToken, + 'visits': visits, + }, + ); +} + class Client extends _i1.ServerpodClientShared { Client( String host, { @@ -172,7 +227,7 @@ class Client extends _i1.ServerpodClientShared { bool? disconnectStreamsOnLostInternetConnection, }) : super( host, - _i8.Protocol(), + _i11.Protocol(), securityContext: securityContext, streamingConnectionTimeout: streamingConnectionTimeout, connectionTimeout: connectionTimeout, @@ -184,7 +239,9 @@ class Client extends _i1.ServerpodClientShared { alerts = EndpointAlerts(this); auth = EndpointAuth(this); health = EndpointHealth(this); + patients = EndpointPatients(this); triage = EndpointTriage(this); + visits = EndpointVisits(this); } late final EndpointAlerts alerts; @@ -193,14 +250,20 @@ class Client extends _i1.ServerpodClientShared { late final EndpointHealth health; + late final EndpointPatients patients; + late final EndpointTriage triage; + late final EndpointVisits visits; + @override Map get endpointRefLookup => { 'alerts': alerts, 'auth': auth, 'health': health, + 'patients': patients, 'triage': triage, + 'visits': visits, }; @override diff --git a/backend/sinalacs_client/lib/src/protocol/enums/sync_status.dart b/backend/sinalacs_client/lib/src/protocol/enums/sync_status.dart index ddf7506..dc7070b 100644 --- a/backend/sinalacs_client/lib/src/protocol/enums/sync_status.dart +++ b/backend/sinalacs_client/lib/src/protocol/enums/sync_status.dart @@ -14,11 +14,18 @@ import 'package:serverpod_client/serverpod_client.dart' as _i1; /// Estado de sincronização offline-first, espelha a SyncFsm. +/// +/// `rejected` é TERMINAL: ao contrário de `error`, que é retentável (o +/// dispositivo tenta de novo mais tarde), uma visita `rejected` nunca vai dar +/// certo numa próxima tentativa — o motivo não muda com o tempo (ex.: paciente +/// fora da microárea do ACS, identificador malformado). `SyncFsm.rejected` não +/// tem transição de saída; `networkUp`/`syncStart` não o alcançam. enum SyncStatus implements _i1.SerializableModel { pending, synced, conflict, - error; + error, + rejected; static SyncStatus fromJson(String name) { switch (name) { @@ -30,6 +37,8 @@ enum SyncStatus implements _i1.SerializableModel { return SyncStatus.conflict; case 'error': return SyncStatus.error; + case 'rejected': + return SyncStatus.rejected; default: throw ArgumentError( 'Value "$name" cannot be converted to "SyncStatus"', diff --git a/backend/sinalacs_client/lib/src/protocol/protocol.dart b/backend/sinalacs_client/lib/src/protocol/protocol.dart index 23959d5..e90b0a2 100644 --- a/backend/sinalacs_client/lib/src/protocol/protocol.dart +++ b/backend/sinalacs_client/lib/src/protocol/protocol.dart @@ -19,26 +19,34 @@ import 'alert_idempotency_key.dart' as _i5; import 'alert_outbox_entry.dart' as _i6; import 'api/alert_ack_result.dart' as _i7; import 'api/development_login_result.dart' as _i8; -import 'api/red_alert_result.dart' as _i9; -import 'api/service_health.dart' as _i10; -import 'api/triage_result.dart' as _i11; -import 'audit_log.dart' as _i12; -import 'consent_log.dart' as _i13; -import 'enums/alert_status.dart' as _i14; -import 'enums/risk_level.dart' as _i15; -import 'enums/sync_status.dart' as _i16; -import 'enums/user_role.dart' as _i17; -import 'exceptions/alert_dispatch_unavailable_exception.dart' as _i18; -import 'exceptions/alert_permission_exception.dart' as _i19; -import 'exceptions/alert_validation_exception.dart' as _i20; -import 'exceptions/endpoint_disabled_exception.dart' as _i21; -import 'micro_area.dart' as _i22; -import 'patient.dart' as _i23; -import 'triage_answer.dart' as _i24; -import 'triage_session.dart' as _i25; -import 'ubs.dart' as _i26; -import 'user.dart' as _i27; -import 'visit.dart' as _i28; +import 'api/micro_area_patient.dart' as _i9; +import 'api/red_alert_result.dart' as _i10; +import 'api/service_health.dart' as _i11; +import 'api/triage_result.dart' as _i12; +import 'api/visit_sync_entry.dart' as _i13; +import 'api/visit_sync_result.dart' as _i14; +import 'audit_log.dart' as _i15; +import 'consent_log.dart' as _i16; +import 'enums/alert_status.dart' as _i17; +import 'enums/risk_level.dart' as _i18; +import 'enums/sync_status.dart' as _i19; +import 'enums/user_role.dart' as _i20; +import 'exceptions/alert_dispatch_unavailable_exception.dart' as _i21; +import 'exceptions/alert_permission_exception.dart' as _i22; +import 'exceptions/alert_validation_exception.dart' as _i23; +import 'exceptions/endpoint_disabled_exception.dart' as _i24; +import 'micro_area.dart' as _i25; +import 'patient.dart' as _i26; +import 'triage_answer.dart' as _i27; +import 'triage_session.dart' as _i28; +import 'ubs.dart' as _i29; +import 'user.dart' as _i30; +import 'visit.dart' as _i31; +import 'package:sinalacs_client/src/protocol/api/micro_area_patient.dart' + as _i32; +import 'package:sinalacs_client/src/protocol/api/visit_sync_result.dart' + as _i33; +import 'package:sinalacs_client/src/protocol/api/visit_sync_entry.dart' as _i34; export 'acs.dart'; export 'alert.dart'; export 'alert_delivery_record.dart'; @@ -46,9 +54,12 @@ export 'alert_idempotency_key.dart'; export 'alert_outbox_entry.dart'; export 'api/alert_ack_result.dart'; export 'api/development_login_result.dart'; +export 'api/micro_area_patient.dart'; export 'api/red_alert_result.dart'; export 'api/service_health.dart'; export 'api/triage_result.dart'; +export 'api/visit_sync_entry.dart'; +export 'api/visit_sync_result.dart'; export 'audit_log.dart'; export 'consent_log.dart'; export 'enums/alert_status.dart'; @@ -123,65 +134,74 @@ class Protocol extends _i1.SerializationManager { if (t == _i8.DevelopmentLoginResult) { return _i8.DevelopmentLoginResult.fromJson(data) as T; } - if (t == _i9.RedAlertResult) { - return _i9.RedAlertResult.fromJson(data) as T; + if (t == _i9.MicroAreaPatient) { + return _i9.MicroAreaPatient.fromJson(data) as T; } - if (t == _i10.ServiceHealth) { - return _i10.ServiceHealth.fromJson(data) as T; + if (t == _i10.RedAlertResult) { + return _i10.RedAlertResult.fromJson(data) as T; } - if (t == _i11.TriageResult) { - return _i11.TriageResult.fromJson(data) as T; + if (t == _i11.ServiceHealth) { + return _i11.ServiceHealth.fromJson(data) as T; } - if (t == _i12.AuditLog) { - return _i12.AuditLog.fromJson(data) as T; + if (t == _i12.TriageResult) { + return _i12.TriageResult.fromJson(data) as T; } - if (t == _i13.ConsentLog) { - return _i13.ConsentLog.fromJson(data) as T; + if (t == _i13.VisitSyncEntry) { + return _i13.VisitSyncEntry.fromJson(data) as T; } - if (t == _i14.AlertStatus) { - return _i14.AlertStatus.fromJson(data) as T; + if (t == _i14.VisitSyncResult) { + return _i14.VisitSyncResult.fromJson(data) as T; } - if (t == _i15.RiskLevel) { - return _i15.RiskLevel.fromJson(data) as T; + if (t == _i15.AuditLog) { + return _i15.AuditLog.fromJson(data) as T; } - if (t == _i16.SyncStatus) { - return _i16.SyncStatus.fromJson(data) as T; + if (t == _i16.ConsentLog) { + return _i16.ConsentLog.fromJson(data) as T; } - if (t == _i17.UserRole) { - return _i17.UserRole.fromJson(data) as T; + if (t == _i17.AlertStatus) { + return _i17.AlertStatus.fromJson(data) as T; } - if (t == _i18.AlertDispatchUnavailableException) { - return _i18.AlertDispatchUnavailableException.fromJson(data) as T; + if (t == _i18.RiskLevel) { + return _i18.RiskLevel.fromJson(data) as T; } - if (t == _i19.AlertPermissionException) { - return _i19.AlertPermissionException.fromJson(data) as T; + if (t == _i19.SyncStatus) { + return _i19.SyncStatus.fromJson(data) as T; } - if (t == _i20.AlertValidationException) { - return _i20.AlertValidationException.fromJson(data) as T; + if (t == _i20.UserRole) { + return _i20.UserRole.fromJson(data) as T; } - if (t == _i21.EndpointDisabledException) { - return _i21.EndpointDisabledException.fromJson(data) as T; + if (t == _i21.AlertDispatchUnavailableException) { + return _i21.AlertDispatchUnavailableException.fromJson(data) as T; } - if (t == _i22.MicroArea) { - return _i22.MicroArea.fromJson(data) as T; + if (t == _i22.AlertPermissionException) { + return _i22.AlertPermissionException.fromJson(data) as T; } - if (t == _i23.Patient) { - return _i23.Patient.fromJson(data) as T; + if (t == _i23.AlertValidationException) { + return _i23.AlertValidationException.fromJson(data) as T; } - if (t == _i24.TriageAnswer) { - return _i24.TriageAnswer.fromJson(data) as T; + if (t == _i24.EndpointDisabledException) { + return _i24.EndpointDisabledException.fromJson(data) as T; } - if (t == _i25.TriageSession) { - return _i25.TriageSession.fromJson(data) as T; + if (t == _i25.MicroArea) { + return _i25.MicroArea.fromJson(data) as T; } - if (t == _i26.Ubs) { - return _i26.Ubs.fromJson(data) as T; + if (t == _i26.Patient) { + return _i26.Patient.fromJson(data) as T; } - if (t == _i27.User) { - return _i27.User.fromJson(data) as T; + if (t == _i27.TriageAnswer) { + return _i27.TriageAnswer.fromJson(data) as T; } - if (t == _i28.Visit) { - return _i28.Visit.fromJson(data) as T; + if (t == _i28.TriageSession) { + return _i28.TriageSession.fromJson(data) as T; + } + if (t == _i29.Ubs) { + return _i29.Ubs.fromJson(data) as T; + } + if (t == _i30.User) { + return _i30.User.fromJson(data) as T; + } + if (t == _i31.Visit) { + return _i31.Visit.fromJson(data) as T; } if (t == _i1.getType<_i2.Acs?>()) { return (data != null ? _i2.Acs.fromJson(data) : null) as T; @@ -207,93 +227,120 @@ class Protocol extends _i1.SerializationManager { return (data != null ? _i8.DevelopmentLoginResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i9.RedAlertResult?>()) { - return (data != null ? _i9.RedAlertResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i9.MicroAreaPatient?>()) { + return (data != null ? _i9.MicroAreaPatient.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i10.RedAlertResult?>()) { + return (data != null ? _i10.RedAlertResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i10.ServiceHealth?>()) { - return (data != null ? _i10.ServiceHealth.fromJson(data) : null) as T; + if (t == _i1.getType<_i11.ServiceHealth?>()) { + return (data != null ? _i11.ServiceHealth.fromJson(data) : null) as T; } - if (t == _i1.getType<_i11.TriageResult?>()) { - return (data != null ? _i11.TriageResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i12.TriageResult?>()) { + return (data != null ? _i12.TriageResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i12.AuditLog?>()) { - return (data != null ? _i12.AuditLog.fromJson(data) : null) as T; + if (t == _i1.getType<_i13.VisitSyncEntry?>()) { + return (data != null ? _i13.VisitSyncEntry.fromJson(data) : null) as T; } - if (t == _i1.getType<_i13.ConsentLog?>()) { - return (data != null ? _i13.ConsentLog.fromJson(data) : null) as T; + if (t == _i1.getType<_i14.VisitSyncResult?>()) { + return (data != null ? _i14.VisitSyncResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i14.AlertStatus?>()) { - return (data != null ? _i14.AlertStatus.fromJson(data) : null) as T; + if (t == _i1.getType<_i15.AuditLog?>()) { + return (data != null ? _i15.AuditLog.fromJson(data) : null) as T; } - if (t == _i1.getType<_i15.RiskLevel?>()) { - return (data != null ? _i15.RiskLevel.fromJson(data) : null) as T; + if (t == _i1.getType<_i16.ConsentLog?>()) { + return (data != null ? _i16.ConsentLog.fromJson(data) : null) as T; } - if (t == _i1.getType<_i16.SyncStatus?>()) { - return (data != null ? _i16.SyncStatus.fromJson(data) : null) as T; + if (t == _i1.getType<_i17.AlertStatus?>()) { + return (data != null ? _i17.AlertStatus.fromJson(data) : null) as T; } - if (t == _i1.getType<_i17.UserRole?>()) { - return (data != null ? _i17.UserRole.fromJson(data) : null) as T; + if (t == _i1.getType<_i18.RiskLevel?>()) { + return (data != null ? _i18.RiskLevel.fromJson(data) : null) as T; } - if (t == _i1.getType<_i18.AlertDispatchUnavailableException?>()) { + if (t == _i1.getType<_i19.SyncStatus?>()) { + return (data != null ? _i19.SyncStatus.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i20.UserRole?>()) { + return (data != null ? _i20.UserRole.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i21.AlertDispatchUnavailableException?>()) { return (data != null - ? _i18.AlertDispatchUnavailableException.fromJson(data) + ? _i21.AlertDispatchUnavailableException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i19.AlertPermissionException?>()) { + if (t == _i1.getType<_i22.AlertPermissionException?>()) { return (data != null - ? _i19.AlertPermissionException.fromJson(data) + ? _i22.AlertPermissionException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i20.AlertValidationException?>()) { + if (t == _i1.getType<_i23.AlertValidationException?>()) { return (data != null - ? _i20.AlertValidationException.fromJson(data) + ? _i23.AlertValidationException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i21.EndpointDisabledException?>()) { + if (t == _i1.getType<_i24.EndpointDisabledException?>()) { return (data != null - ? _i21.EndpointDisabledException.fromJson(data) + ? _i24.EndpointDisabledException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i22.MicroArea?>()) { - return (data != null ? _i22.MicroArea.fromJson(data) : null) as T; + if (t == _i1.getType<_i25.MicroArea?>()) { + return (data != null ? _i25.MicroArea.fromJson(data) : null) as T; } - if (t == _i1.getType<_i23.Patient?>()) { - return (data != null ? _i23.Patient.fromJson(data) : null) as T; + if (t == _i1.getType<_i26.Patient?>()) { + return (data != null ? _i26.Patient.fromJson(data) : null) as T; } - if (t == _i1.getType<_i24.TriageAnswer?>()) { - return (data != null ? _i24.TriageAnswer.fromJson(data) : null) as T; + if (t == _i1.getType<_i27.TriageAnswer?>()) { + return (data != null ? _i27.TriageAnswer.fromJson(data) : null) as T; } - if (t == _i1.getType<_i25.TriageSession?>()) { - return (data != null ? _i25.TriageSession.fromJson(data) : null) as T; + if (t == _i1.getType<_i28.TriageSession?>()) { + return (data != null ? _i28.TriageSession.fromJson(data) : null) as T; } - if (t == _i1.getType<_i26.Ubs?>()) { - return (data != null ? _i26.Ubs.fromJson(data) : null) as T; + if (t == _i1.getType<_i29.Ubs?>()) { + return (data != null ? _i29.Ubs.fromJson(data) : null) as T; } - if (t == _i1.getType<_i27.User?>()) { - return (data != null ? _i27.User.fromJson(data) : null) as T; + if (t == _i1.getType<_i30.User?>()) { + return (data != null ? _i30.User.fromJson(data) : null) as T; } - if (t == _i1.getType<_i28.Visit?>()) { - return (data != null ? _i28.Visit.fromJson(data) : null) as T; + if (t == _i1.getType<_i31.Visit?>()) { + return (data != null ? _i31.Visit.fromJson(data) : null) as T; } if (t == List) { return (data as List).map((e) => deserialize(e)).toList() as T; } - if (t == List<_i24.TriageAnswer>) { - return (data as List) - .map((e) => deserialize<_i24.TriageAnswer>(e)) - .toList() - as T; - } if (t == Map) { return (data as Map).map( (k, v) => MapEntry(deserialize(k), deserialize(v)), ) as T; } + if (t == List<_i27.TriageAnswer>) { + return (data as List) + .map((e) => deserialize<_i27.TriageAnswer>(e)) + .toList() + as T; + } + if (t == List<_i32.MicroAreaPatient>) { + return (data as List) + .map((e) => deserialize<_i32.MicroAreaPatient>(e)) + .toList() + as T; + } + if (t == List<_i33.VisitSyncResult>) { + return (data as List) + .map((e) => deserialize<_i33.VisitSyncResult>(e)) + .toList() + as T; + } + if (t == List<_i34.VisitSyncEntry>) { + return (data as List) + .map((e) => deserialize<_i34.VisitSyncEntry>(e)) + .toList() + as T; + } return super.deserialize(data, t); } @@ -306,27 +353,30 @@ class Protocol extends _i1.SerializationManager { _i6.AlertOutboxEntry => 'AlertOutboxEntry', _i7.AlertAckResult => 'AlertAckResult', _i8.DevelopmentLoginResult => 'DevelopmentLoginResult', - _i9.RedAlertResult => 'RedAlertResult', - _i10.ServiceHealth => 'ServiceHealth', - _i11.TriageResult => 'TriageResult', - _i12.AuditLog => 'AuditLog', - _i13.ConsentLog => 'ConsentLog', - _i14.AlertStatus => 'AlertStatus', - _i15.RiskLevel => 'RiskLevel', - _i16.SyncStatus => 'SyncStatus', - _i17.UserRole => 'UserRole', - _i18.AlertDispatchUnavailableException => + _i9.MicroAreaPatient => 'MicroAreaPatient', + _i10.RedAlertResult => 'RedAlertResult', + _i11.ServiceHealth => 'ServiceHealth', + _i12.TriageResult => 'TriageResult', + _i13.VisitSyncEntry => 'VisitSyncEntry', + _i14.VisitSyncResult => 'VisitSyncResult', + _i15.AuditLog => 'AuditLog', + _i16.ConsentLog => 'ConsentLog', + _i17.AlertStatus => 'AlertStatus', + _i18.RiskLevel => 'RiskLevel', + _i19.SyncStatus => 'SyncStatus', + _i20.UserRole => 'UserRole', + _i21.AlertDispatchUnavailableException => 'AlertDispatchUnavailableException', - _i19.AlertPermissionException => 'AlertPermissionException', - _i20.AlertValidationException => 'AlertValidationException', - _i21.EndpointDisabledException => 'EndpointDisabledException', - _i22.MicroArea => 'MicroArea', - _i23.Patient => 'Patient', - _i24.TriageAnswer => 'TriageAnswer', - _i25.TriageSession => 'TriageSession', - _i26.Ubs => 'Ubs', - _i27.User => 'User', - _i28.Visit => 'Visit', + _i22.AlertPermissionException => 'AlertPermissionException', + _i23.AlertValidationException => 'AlertValidationException', + _i24.EndpointDisabledException => 'EndpointDisabledException', + _i25.MicroArea => 'MicroArea', + _i26.Patient => 'Patient', + _i27.TriageAnswer => 'TriageAnswer', + _i28.TriageSession => 'TriageSession', + _i29.Ubs => 'Ubs', + _i30.User => 'User', + _i31.Visit => 'Visit', _ => null, }; } @@ -355,45 +405,51 @@ class Protocol extends _i1.SerializationManager { return 'AlertAckResult'; case _i8.DevelopmentLoginResult(): return 'DevelopmentLoginResult'; - case _i9.RedAlertResult(): + case _i9.MicroAreaPatient(): + return 'MicroAreaPatient'; + case _i10.RedAlertResult(): return 'RedAlertResult'; - case _i10.ServiceHealth(): + case _i11.ServiceHealth(): return 'ServiceHealth'; - case _i11.TriageResult(): + case _i12.TriageResult(): return 'TriageResult'; - case _i12.AuditLog(): + case _i13.VisitSyncEntry(): + return 'VisitSyncEntry'; + case _i14.VisitSyncResult(): + return 'VisitSyncResult'; + case _i15.AuditLog(): return 'AuditLog'; - case _i13.ConsentLog(): + case _i16.ConsentLog(): return 'ConsentLog'; - case _i14.AlertStatus(): + case _i17.AlertStatus(): return 'AlertStatus'; - case _i15.RiskLevel(): + case _i18.RiskLevel(): return 'RiskLevel'; - case _i16.SyncStatus(): + case _i19.SyncStatus(): return 'SyncStatus'; - case _i17.UserRole(): + case _i20.UserRole(): return 'UserRole'; - case _i18.AlertDispatchUnavailableException(): + case _i21.AlertDispatchUnavailableException(): return 'AlertDispatchUnavailableException'; - case _i19.AlertPermissionException(): + case _i22.AlertPermissionException(): return 'AlertPermissionException'; - case _i20.AlertValidationException(): + case _i23.AlertValidationException(): return 'AlertValidationException'; - case _i21.EndpointDisabledException(): + case _i24.EndpointDisabledException(): return 'EndpointDisabledException'; - case _i22.MicroArea(): + case _i25.MicroArea(): return 'MicroArea'; - case _i23.Patient(): + case _i26.Patient(): return 'Patient'; - case _i24.TriageAnswer(): + case _i27.TriageAnswer(): return 'TriageAnswer'; - case _i25.TriageSession(): + case _i28.TriageSession(): return 'TriageSession'; - case _i26.Ubs(): + case _i29.Ubs(): return 'Ubs'; - case _i27.User(): + case _i30.User(): return 'User'; - case _i28.Visit(): + case _i31.Visit(): return 'Visit'; } return null; @@ -426,65 +482,74 @@ class Protocol extends _i1.SerializationManager { if (dataClassName == 'DevelopmentLoginResult') { return deserialize<_i8.DevelopmentLoginResult>(data['data']); } + if (dataClassName == 'MicroAreaPatient') { + return deserialize<_i9.MicroAreaPatient>(data['data']); + } if (dataClassName == 'RedAlertResult') { - return deserialize<_i9.RedAlertResult>(data['data']); + return deserialize<_i10.RedAlertResult>(data['data']); } if (dataClassName == 'ServiceHealth') { - return deserialize<_i10.ServiceHealth>(data['data']); + return deserialize<_i11.ServiceHealth>(data['data']); } if (dataClassName == 'TriageResult') { - return deserialize<_i11.TriageResult>(data['data']); + return deserialize<_i12.TriageResult>(data['data']); + } + if (dataClassName == 'VisitSyncEntry') { + return deserialize<_i13.VisitSyncEntry>(data['data']); + } + if (dataClassName == 'VisitSyncResult') { + return deserialize<_i14.VisitSyncResult>(data['data']); } if (dataClassName == 'AuditLog') { - return deserialize<_i12.AuditLog>(data['data']); + return deserialize<_i15.AuditLog>(data['data']); } if (dataClassName == 'ConsentLog') { - return deserialize<_i13.ConsentLog>(data['data']); + return deserialize<_i16.ConsentLog>(data['data']); } if (dataClassName == 'AlertStatus') { - return deserialize<_i14.AlertStatus>(data['data']); + return deserialize<_i17.AlertStatus>(data['data']); } if (dataClassName == 'RiskLevel') { - return deserialize<_i15.RiskLevel>(data['data']); + return deserialize<_i18.RiskLevel>(data['data']); } if (dataClassName == 'SyncStatus') { - return deserialize<_i16.SyncStatus>(data['data']); + return deserialize<_i19.SyncStatus>(data['data']); } if (dataClassName == 'UserRole') { - return deserialize<_i17.UserRole>(data['data']); + return deserialize<_i20.UserRole>(data['data']); } if (dataClassName == 'AlertDispatchUnavailableException') { - return deserialize<_i18.AlertDispatchUnavailableException>(data['data']); + return deserialize<_i21.AlertDispatchUnavailableException>(data['data']); } if (dataClassName == 'AlertPermissionException') { - return deserialize<_i19.AlertPermissionException>(data['data']); + return deserialize<_i22.AlertPermissionException>(data['data']); } if (dataClassName == 'AlertValidationException') { - return deserialize<_i20.AlertValidationException>(data['data']); + return deserialize<_i23.AlertValidationException>(data['data']); } if (dataClassName == 'EndpointDisabledException') { - return deserialize<_i21.EndpointDisabledException>(data['data']); + return deserialize<_i24.EndpointDisabledException>(data['data']); } if (dataClassName == 'MicroArea') { - return deserialize<_i22.MicroArea>(data['data']); + return deserialize<_i25.MicroArea>(data['data']); } if (dataClassName == 'Patient') { - return deserialize<_i23.Patient>(data['data']); + return deserialize<_i26.Patient>(data['data']); } if (dataClassName == 'TriageAnswer') { - return deserialize<_i24.TriageAnswer>(data['data']); + return deserialize<_i27.TriageAnswer>(data['data']); } if (dataClassName == 'TriageSession') { - return deserialize<_i25.TriageSession>(data['data']); + return deserialize<_i28.TriageSession>(data['data']); } if (dataClassName == 'Ubs') { - return deserialize<_i26.Ubs>(data['data']); + return deserialize<_i29.Ubs>(data['data']); } if (dataClassName == 'User') { - return deserialize<_i27.User>(data['data']); + return deserialize<_i30.User>(data['data']); } if (dataClassName == 'Visit') { - return deserialize<_i28.Visit>(data['data']); + return deserialize<_i31.Visit>(data['data']); } return super.deserializeByClassName(data); } diff --git a/backend/sinalacs_server/bin/audit_chain_check.dart b/backend/sinalacs_server/bin/audit_chain_check.dart new file mode 100644 index 0000000..a53d470 --- /dev/null +++ b/backend/sinalacs_server/bin/audit_chain_check.dart @@ -0,0 +1,52 @@ +import 'dart:io'; + +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/audit/audit_chain_verifier.dart'; +import 'package:sinalacs_server/src/generated/endpoints.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_audit_chain_reader.dart'; +import 'package:sinalacs_server/src/runtime/alert_runtime.dart'; + +/// Verifica a cadeia de hash de `audit_logs` (LGPD-RT03) contra o banco +/// configurado em `config/*.yaml` — a mesma configuração que o servidor usa. +/// +/// Uso: `dart run bin/audit_chain_check.dart` +/// +/// Sai `0` quando a cadeia está íntegra, `1` quando encontra uma quebra +/// (edição, remoção ou reordenação de linha), imprimindo em que `sequence` e +/// por quê. Não sobe nenhum listener HTTP — só abre uma sessão interna para +/// ler o banco, no mesmo padrão de script avulso que o Serverpod documenta +/// para tarefas de manutenção. +Future main(List args) async { + final pod = Serverpod(args, Protocol(), Endpoints()); + final session = await pod.createSession(); + + try { + final verifier = AuditChainVerifier( + reader: OrmAuditChainReader(session: () => session), + secret: AlertRuntime.instance.config.auditChainSecret, + ); + final result = await verifier.verify(); + + if (result.ok) { + stdout.writeln( + 'OK — ${result.checked} linha(s) verificada(s), cadeia íntegra.', + ); + exitCode = 0; + } else { + stderr.writeln( + 'FALHA na sequence ${result.brokenAtSequence}: ${result.reason} ' + '(${result.checked} linha(s) íntegra(s) antes da quebra)', + ); + exitCode = 1; + } + } finally { + await session.close(); + // `exitProcess: true` (o padrão) chamaria `exit()` internamente com seu + // próprio código, descartando o `exitCode` que acabamos de definir acima — + // é assim que o script perderia o `1` de uma cadeia quebrada. + await pod.shutdown(exitProcess: false); + } + + exit(exitCode); +} diff --git a/backend/sinalacs_server/docker-compose.yaml b/backend/sinalacs_server/docker-compose.yaml deleted file mode 100644 index 4ca6b4b..0000000 --- a/backend/sinalacs_server/docker-compose.yaml +++ /dev/null @@ -1,44 +0,0 @@ -services: - # Development services - postgres: - image: pgvector/pgvector:pg16 - ports: - - "8090:5432" - environment: - POSTGRES_USER: postgres - POSTGRES_DB: sinalacs - POSTGRES_PASSWORD: "ZGib82sxRnAaTto3iglbPkARA1qZUv0W" - volumes: - - sinalacs_data:/var/lib/postgresql/data - - redis: - image: redis:6.2.6 - ports: - - "8091:6379" - command: redis-server --requirepass "xjjE2lgOgChcj15Gf5thYrjs1gzUoF4n" - environment: - - REDIS_REPLICATION_MODE=master - - # Test services - postgres_test: - image: pgvector/pgvector:pg16 - ports: - - "9090:5432" - environment: - POSTGRES_USER: postgres - POSTGRES_DB: sinalacs_test - POSTGRES_PASSWORD: "ehzaftIZDV8Ou4ILUIRsE02VhbjvBGZB" - volumes: - - sinalacs_test_data:/var/lib/postgresql/data - - redis_test: - image: redis:6.2.6 - ports: - - "9091:6379" - command: redis-server --requirepass "b0Wa06K7wU5MZbrAdXk6VrRyvOtmsaWS" - environment: - - REDIS_REPLICATION_MODE=master - -volumes: - sinalacs_data: - sinalacs_test_data: diff --git a/backend/sinalacs_server/lib/src/application/audit/audit_chain.dart b/backend/sinalacs_server/lib/src/application/audit/audit_chain.dart new file mode 100644 index 0000000..6062a49 --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/audit/audit_chain.dart @@ -0,0 +1,88 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; + +/// Os campos de uma linha de `audit_logs` que entram no cálculo da cadeia de +/// hash, na forma que `application/` conhece — não o `AuditLog` gerado, para +/// não acoplar este cálculo ao ORM. +class AuditChainFields { + const AuditChainFields({ + required this.sequence, + required this.previousHash, + required this.userId, + required this.actionType, + required this.resourceType, + required this.resourceId, + required this.timestamp, + required this.ipHash, + required this.result, + }); + + final int sequence; + final String previousHash; + final String userId; + final String actionType; + final String resourceType; + final String? resourceId; + final DateTime timestamp; + final String ipHash; + final String result; +} + +/// Calcula o elo de uma cadeia de hash sobre `audit_logs` (LGPD-RT03). +/// +/// Cada linha encadeia à anterior por `previousHash`, e `entryHash` é um +/// HMAC-SHA256 do conteúdo da linha — HMAC, não SHA-256 puro, porque a chave +/// fica fora do Postgres (`AUDIT_CHAIN_SECRET`): quem tem acesso de escrita ao +/// banco consegue recalcular um hash simples depois de adulterar uma linha, +/// mas não consegue forjar uma assinatura sem o segredo. `OrmAuditTrail` +/// escreve com esta classe, e `AuditChainVerifier` lê com ela — o mesmo +/// código dos dois lados, para uma segunda implementação do cálculo não virar +/// a forma mais provável de a verificação mentir. +class AuditChain { + AuditChain({required String secret}) : _secret = utf8.encode(secret); + + final List _secret; + + /// `previousHash` da primeira linha da cadeia. Nunca nulo — gênese e "linha + /// escrita antes de a cadeia existir" não podem ter a mesma representação. + static const genesisHash = '0000000000000000000000000000000000000000000000000000000000000000'; + + /// HMAC-SHA256, em hexadecimal — mesmo formato de `ipHash`, que já usa + /// `.toString()` sobre um `Digest`. Não é base64Url: aquele é o formato de + /// token JWT, um contexto diferente. + String computeEntryHash(AuditChainFields fields) => + Hmac(sha256, _secret).convert(utf8.encode(_payloadOf(fields))).toString(); + + /// Compara em tempo constante, no mesmo molde de + /// `DevelopmentAuthService._matchesSignature` — o campo é uma assinatura, + /// então comparar com `==` vazaria timing. + bool matches(AuditChainFields fields, String entryHash) { + final expected = computeEntryHash(fields); + if (expected.length != entryHash.length) return false; + var difference = 0; + for (var index = 0; index < expected.length; index++) { + difference |= expected.codeUnitAt(index) ^ entryHash.codeUnitAt(index); + } + return difference == 0; + } + + /// U+001F (INFORMATION SEPARATOR ONE / "unit separator"), o caractere de + /// controle — não o símbolo visível ␟ (U+241F), que é outro código: não + /// aparece em UUID, em hex, em ISO-8601 nem nos valores de + /// `actionType`/`result` já em uso, então concatenar sem ele não faz dois + /// conjuntos de campos diferentes colidirem no mesmo payload. + static const _separator = '\u001F'; + + String _payloadOf(AuditChainFields fields) => [ + fields.sequence.toString(), + fields.previousHash, + fields.userId, + fields.actionType, + fields.resourceType, + fields.resourceId ?? '', + fields.timestamp.toUtc().toIso8601String(), + fields.ipHash, + fields.result, + ].join(_separator); +} diff --git a/backend/sinalacs_server/lib/src/application/audit/audit_chain_verifier.dart b/backend/sinalacs_server/lib/src/application/audit/audit_chain_verifier.dart new file mode 100644 index 0000000..ceffecc --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/audit/audit_chain_verifier.dart @@ -0,0 +1,108 @@ +import 'package:sinalacs_server/src/application/audit/audit_chain.dart'; + +/// Uma linha de `audit_logs` como a cadeia a vê: os campos que entram no +/// cálculo, mais o `entryHash` gravado — para o verificador comparar contra o +/// que `AuditChain.computeEntryHash` recalcula. +class AuditChainEntry { + const AuditChainEntry({required this.fields, required this.entryHash}); + + final AuditChainFields fields; + final String entryHash; +} + +/// Fonte das linhas de `audit_logs`, em ordem de `sequence` — implementada +/// sobre o ORM em `infrastructure/database/`, no mesmo molde de `AlertStore`/ +/// `VisitStore`: a interface fica em `application/` para o verificador ser +/// testável sem Postgres. +abstract interface class AuditChainReader { + Future> readInOrder(); +} + +/// Resultado de uma verificação da cadeia. +/// +/// `checked` conta quantas linhas confirmaram elo e hash antes de uma quebra +/// (ou o total, quando `ok` é `true`) — útil para dizer "as primeiras N linhas +/// estão intactas" mesmo quando a N+1 não está. +class AuditChainVerification { + const AuditChainVerification({ + required this.ok, + required this.checked, + this.brokenAtSequence, + this.reason, + }); + + final bool ok; + final int checked; + final int? brokenAtSequence; + final String? reason; +} + +/// Verifica a integridade da cadeia de hash de `audit_logs` (LGPD-RT03). +/// +/// Usa a MESMA [AuditChain] que grava as linhas: uma segunda implementação do +/// cálculo de hash aqui seria a forma mais provável de a verificação mentir. +/// +/// Três checagens, nesta ordem — cada uma cobre um jeito diferente de +/// adulterar a trilha: +/// 1. `sequence` contígua a partir de 1 — detecta linha **apagada**; +/// 2. `previousHash` da linha bate com o `entryHash` da anterior (e a +/// primeira usa [AuditChain.genesisHash]) — detecta **reordenação** e +/// **inserção**; +/// 3. `entryHash` recalculado bate com o gravado — detecta **edição de +/// conteúdo**, e forjar um hash que bata exige o segredo. +class AuditChainVerifier { + AuditChainVerifier({ + required AuditChainReader reader, + required String secret, + }) : _reader = reader, + _chain = AuditChain(secret: secret); + + final AuditChainReader _reader; + final AuditChain _chain; + + Future verify() async { + final entries = await _reader.readInOrder(); + + var expectedSequence = 1; + var expectedPreviousHash = AuditChain.genesisHash; + + for (final entry in entries) { + final fields = entry.fields; + + if (fields.sequence != expectedSequence) { + return AuditChainVerification( + ok: false, + checked: expectedSequence - 1, + brokenAtSequence: fields.sequence, + reason: 'sequência descontínua: esperava $expectedSequence, ' + 'encontrou ${fields.sequence} (linha apagada ou fora de ordem)', + ); + } + + if (fields.previousHash != expectedPreviousHash) { + return AuditChainVerification( + ok: false, + checked: expectedSequence - 1, + brokenAtSequence: fields.sequence, + reason: 'elo quebrado: previousHash não confere com a linha ' + 'anterior (possível reordenação ou inserção)', + ); + } + + if (!_chain.matches(fields, entry.entryHash)) { + return AuditChainVerification( + ok: false, + checked: expectedSequence - 1, + brokenAtSequence: fields.sequence, + reason: 'hash não confere com o conteúdo da linha ' + '(possível adulteração)', + ); + } + + expectedPreviousHash = entry.entryHash; + expectedSequence++; + } + + return AuditChainVerification(ok: true, checked: entries.length); + } +} diff --git a/backend/sinalacs_server/lib/src/application/audit/audit_trail.dart b/backend/sinalacs_server/lib/src/application/audit/audit_trail.dart new file mode 100644 index 0000000..91025ae --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/audit/audit_trail.dart @@ -0,0 +1,63 @@ +import 'dart:io'; + +/// Um evento de acesso a dado sensível, pronto para a trilha. +/// +/// Não é o `AuditLog` gerado: este é o formato que os serviços de aplicação +/// conhecem, sem acoplar `application/` ao ORM. +class AuditEvent { + const AuditEvent({ + required this.userId, + required this.actionType, + required this.resourceType, + required this.result, + this.resourceId, + }); + + final String userId; + + /// `read` ou `write` — o que a §132 de spec/lgpd_design.md chama de "quais + /// dados". + final String actionType; + + final String resourceType; + + /// De propósito opcional: uma leitura de LISTA (ex.: o diretório de + /// pacientes da microárea) não enumera cada paciente aqui — fazer isso + /// recriaria o prontuário dentro do próprio log de auditoria. Só eventos + /// sobre UM recurso específico (uma recusa de sincronização, por exemplo) + /// preenchem este campo. + final String? resourceId; + + /// `granted`, `denied_territory`, etc. — nunca contém dado do paciente. + final String result; +} + +/// Trilha de auditoria de acesso a dados sensíveis (append-only). +/// +/// `audit_logs` existe desde a migração-base e, até este serviço, não tinha +/// escritor nenhum — a spec de LGPD promete "logs de acesso com quem, quando e +/// quais dados" (§132) e "alerta para auditoria" quando um ACS tenta um +/// paciente fora da própria microárea (§404), e nada gravava para cumprir isso. +/// +/// Uma `abstract class` comum, não `abstract interface class`: as outras +/// interfaces deste `application/` (`AlertStore`, `VisitStore`) são puras de +/// propósito, mas aqui o `recordSafely` precisa ser herdado por TODO +/// implementador — inclusive o fake de teste que simula falha — em vez de +/// reescrito em cada um. +abstract class AuditTrail { + Future record(AuditEvent event); + + /// Registra sem propagar falha: uma trilha de auditoria fora do ar não pode + /// impedir o ACS de listar pacientes ou de ter sua visita recusada por + /// território — mas a falha não pode desaparecer, então vai para o log do + /// processo. + Future recordSafely(AuditEvent event) async { + try { + await record(event); + } catch (error) { + stderr.writeln( + 'Falha ao gravar auditoria (${event.actionType}/${event.resourceType}): $error.', + ); + } + } +} diff --git a/backend/sinalacs_server/lib/src/application/patients/patient_directory_service.dart b/backend/sinalacs_server/lib/src/application/patients/patient_directory_service.dart new file mode 100644 index 0000000..f3f9e30 --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/patients/patient_directory_service.dart @@ -0,0 +1,75 @@ +import 'package:sinalacs_server/src/application/audit/audit_trail.dart'; +import 'package:sinalacs_server/src/application/auth/development_auth_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; + +/// Um paciente da microárea, do jeito que a store enxerga — sem o formato de +/// transporte do endpoint. +class PatientDirectoryEntry { + const PatientDirectoryEntry({ + required this.patientId, + required this.name, + required this.isChronic, + required this.chronicConditions, + }); + + final String patientId; + final String name; + final bool isChronic; + final List chronicConditions; +} + +/// Consulta aos pacientes de uma microárea. +/// +/// Mesmo padrão de `AlertStore`/`VisitStore`: interface aqui, implementação +/// ORM em `infrastructure/`, para o serviço ser testável sem Postgres. +abstract interface class PatientDirectoryStore { + Future> listByMicroArea(String microAreaId); +} + +/// Lista os pacientes da microárea de um ACS, para a rotina de visitas. +/// +/// Existe porque o único produtor de alertas é `RedAlertService` — e ele só +/// publica `riskLevel: 'red'` (emergência, com SAMU). O PRD mede visita como +/// trabalho territorial de rotina (≥ 8/dia por ACS): sem uma lista de +/// pacientes, a "Visita" nunca teria de onde partir fora do caminho reativo. +class PatientDirectoryService { + PatientDirectoryService({ + required PatientDirectoryStore store, + required AuditTrail audit, + }) : _store = store, + _audit = audit; + + final PatientDirectoryStore _store; + final AuditTrail _audit; + + /// A microárea vem SEMPRE do token, nunca de um parâmetro do método — aceitar + /// um `microAreaId` do cliente seria oferecer ao dispositivo a chance de + /// pedir outro território (INV-01). + Future> listForAcs(AuthenticatedUser user) async { + if (user.role != UserRole.acs || user.microAreaId == null) { + throw StateError('Somente ACS territorializados podem listar pacientes.'); + } + + final entries = await _store.listByMicroArea(user.microAreaId!); + + // Best-effort: uma trilha de auditoria que falha não pode impedir o ACS de + // trabalhar. A leitura audita o EVENTO, não os pacientes retornados — listar + // os UUIDs aqui recriaria o prontuário dentro do próprio log de auditoria. + await _audit.recordSafely(AuditEvent( + userId: user.id, + actionType: 'read', + resourceType: 'patient_directory', + result: 'granted', + )); + + return [ + for (final entry in entries) + MicroAreaPatient( + patientId: entry.patientId, + name: entry.name, + isChronic: entry.isChronic, + chronicConditions: entry.chronicConditions, + ), + ]; + } +} diff --git a/backend/sinalacs_server/lib/src/application/sync/sync_fsm.dart b/backend/sinalacs_server/lib/src/application/sync/sync_fsm.dart index 361b274..fe63ad6 100644 --- a/backend/sinalacs_server/lib/src/application/sync/sync_fsm.dart +++ b/backend/sinalacs_server/lib/src/application/sync/sync_fsm.dart @@ -6,6 +6,12 @@ enum SyncState { conflict, synced, error, + + /// Terminal: o servidor recusou em definitivo (ver `VisitSyncService`, + /// `SyncStatus.rejected`). Ao contrário de `error`, nenhum evento de rede + /// tira uma visita deste estado — `networkUp`/`syncStart` não o alcançam, + /// porque o motivo da recusa não muda com uma próxima tentativa. + rejected, } enum SyncEvent { @@ -17,6 +23,7 @@ enum SyncEvent { syncAck, syncConflict, syncError, + syncRejected, } class SyncFsm { @@ -57,6 +64,9 @@ class SyncFsm { case SyncEvent.syncError: state = SyncState.error; break; + case SyncEvent.syncRejected: + state = SyncState.rejected; + break; case SyncEvent.networkDown: if (state == SyncState.syncing || state == SyncState.queued) { state = SyncState.queued; diff --git a/backend/sinalacs_server/lib/src/application/visits/visit_sync_service.dart b/backend/sinalacs_server/lib/src/application/visits/visit_sync_service.dart new file mode 100644 index 0000000..8ea8f45 --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/visits/visit_sync_service.dart @@ -0,0 +1,205 @@ +import 'package:serverpod/serverpod.dart' show UuidValue; +import 'package:sinalacs_server/src/application/audit/audit_trail.dart'; +import 'package:sinalacs_server/src/application/auth/development_auth_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; + +/// Persistência das visitas sincronizadas. +/// +/// Interface no mesmo padrão de `AlertStore`: mantém o serviço testável sem +/// Postgres real. +abstract interface class VisitStore { + /// Visita já gravada com este `localId`, se houver. + Future findByLocalId(String localId); + + /// Grava uma visita nova, já com `version` inicial. + Future insert(Visit visit); + + /// Atualiza uma visita existente, incrementando a versão. + Future update(Visit visit); + + /// Microárea do paciente, ou `null` se ele não existir. + /// + /// Sem isto, o serviço validava que o ACS é territorializado mas nunca que o + /// PACIENTE pertence ao mesmo território — qualquer UUID de paciente + /// existente era aceito, de qualquer microárea (furo do INV-01). + Future microAreaOfPatient(UuidValue patientId); +} + +/// Sincronização das visitas registradas offline pelo ACS. +/// +/// Espelha a `SyncFsm` do lado do dispositivo: cada visita termina em +/// `synced`, `conflict`, `error` ou `rejected`, e conflito **nunca** +/// sobrescreve o que está no servidor — devolve a versão atual para o +/// dispositivo reconciliar. +/// +/// `error` é retentável (rede, paciente ainda não cadastrado); `rejected` é +/// terminal — o motivo não muda com uma próxima tentativa (identificador +/// malformado, território, dono do registro) — e o dispositivo não deve +/// reenviar. +class VisitSyncService { + VisitSyncService({ + required VisitStore store, + required AuditTrail audit, + DateTime Function()? clock, + }) : _store = store, + _audit = audit, + _clock = clock ?? DateTime.now; + + final VisitStore _store; + final AuditTrail _audit; + final DateTime Function() _clock; + + Future> sync({ + required AuthenticatedUser user, + required List entries, + }) async { + if (user.role != UserRole.acs || user.microAreaId == null) { + throw StateError('Somente ACS territorializados podem sincronizar visitas.'); + } + + final results = []; + for (final entry in entries) { + results.add(await _syncOne(user: user, entry: entry)); + } + return results; + } + + Future _syncOne({ + required AuthenticatedUser user, + required VisitSyncEntry entry, + }) async { + if (entry.localId.trim().isEmpty) { + // Terminal: um localId vazio não vira válido reenviando o mesmo lote. + return VisitSyncResult( + localId: entry.localId, + syncStatus: SyncStatus.rejected, + message: 'localId é obrigatório', + ); + } + + final UuidValue localId; + final UuidValue patientId; + try { + // `UuidValue.fromString` NÃO valida — só normaliza para minúsculas. Sem + // `withValidation`, um `localId` malformado entraria no banco e quebraria + // a deduplicação do reenvio, que é justamente o que o índice único + // protege. + localId = UuidValue.withValidation(entry.localId); + patientId = UuidValue.withValidation(entry.patientId); + } on FormatException { + // Terminal: um identificador malformado na origem não vira válido + // reenviando. + return VisitSyncResult( + localId: entry.localId, + syncStatus: SyncStatus.rejected, + message: 'identificadores devem ser UUID', + ); + } + + // Território ANTES de tocar em `existing`: um reenvio para o mesmo + // `localId` também precisa ser barrado, não só a primeira gravação. + final patientMicroAreaId = await _store.microAreaOfPatient(patientId); + if (patientMicroAreaId == null) { + return VisitSyncResult( + localId: entry.localId, + syncStatus: SyncStatus.error, + message: 'paciente não encontrado', + ); + } + + final acsMicroAreaId = UuidValue.fromString(user.microAreaId!); + if (patientMicroAreaId != acsMicroAreaId) { + // A mensagem não cita a microárea alheia nem o nome do paciente — só que + // o vínculo não existe. §404 de spec/lgpd_design.md: "o sistema monitora + // se um ACS consulta dados de um paciente fora de sua microárea sem + // justificativa, gerando alerta para auditoria". + await _audit.recordSafely(AuditEvent( + userId: user.id, + actionType: 'write', + resourceType: 'visit', + resourceId: patientId.uuid, + result: 'denied_territory', + )); + // Terminal: o território não muda por retentar. + return VisitSyncResult( + localId: entry.localId, + syncStatus: SyncStatus.rejected, + message: 'paciente fora da sua microárea', + ); + } + + final acsId = UuidValue.fromString(user.id); + final existing = await _store.findByLocalId(entry.localId); + + if (existing == null) { + final inserted = await _store.insert(Visit( + patientId: patientId, + acsId: acsId, + scheduledAt: entry.scheduledAt, + completedAt: entry.completedAt, + status: entry.status, + riskLevelBefore: entry.riskLevelBefore, + riskLevelAfter: entry.riskLevelAfter, + notes: entry.notes, + syncStatus: SyncStatus.synced, + localId: localId, + syncAt: _clock().toUtc(), + version: 1, + )); + return VisitSyncResult( + localId: entry.localId, + syncStatus: SyncStatus.synced, + serverVersion: inserted.version, + ); + } + + // A visita pertence ao ACS que a registrou. Um ACS não sincroniza a visita + // de outro, mesmo conhecendo o localId. + if (existing.acsId != acsId) { + // Terminal: o dono do registro não muda por retentar. + return VisitSyncResult( + localId: entry.localId, + syncStatus: SyncStatus.rejected, + message: 'visita registrada por outro agente', + ); + } + + // Reenvio do MESMO estado: a rede caiu depois de o servidor gravar, e o + // dispositivo não viu a resposta. Não é conflito — é a idempotência + // funcionando. + if (entry.version == existing.version) { + return VisitSyncResult( + localId: entry.localId, + syncStatus: SyncStatus.synced, + serverVersion: existing.version, + ); + } + + // O dispositivo partiu de uma versão que não é mais a atual: alguém alterou + // a visita no meio. Devolve conflito com a versão do servidor, sem + // sobrescrever nada. + if (entry.version != existing.version - 1) { + return VisitSyncResult( + localId: entry.localId, + syncStatus: SyncStatus.conflict, + serverVersion: existing.version, + ); + } + + final updated = await _store.update(existing.copyWith( + completedAt: entry.completedAt, + status: entry.status, + riskLevelAfter: entry.riskLevelAfter, + notes: entry.notes, + syncStatus: SyncStatus.synced, + syncAt: _clock().toUtc(), + version: existing.version + 1, + )); + + return VisitSyncResult( + localId: entry.localId, + syncStatus: SyncStatus.synced, + serverVersion: updated.version, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/config/app_config.dart b/backend/sinalacs_server/lib/src/config/app_config.dart index b4f0837..4df3a5d 100644 --- a/backend/sinalacs_server/lib/src/config/app_config.dart +++ b/backend/sinalacs_server/lib/src/config/app_config.dart @@ -1,10 +1,15 @@ import 'dart:io'; +/// Configuração que **não** vem do Serverpod. +/// +/// Banco e servidor são configurados por `config/*.yaml` mais as variáveis +/// `SERVERPOD_*`. O que sobra — MQTT e os segredos de autenticação/auditoria — +/// é lido aqui. class AppConfig { const AppConfig({ - required this.databaseUrl, required this.mqttBroker, required this.jwtSecret, + required this.auditChainSecret, required this.mqttUsername, required this.mqttPassword, required this.mqttUseTls, @@ -13,9 +18,22 @@ class AppConfig { required this.enableDevLogin, }); - final String databaseUrl; final String mqttBroker; + + /// Chave HMAC que assina os tokens de `auth.developmentLogin`. + /// + /// O token carrega o papel e a microárea, então quem conhece este valor forja + /// um acesso de ACS para qualquer território. Ver [_resolveJwtSecret]. final String jwtSecret; + + /// Chave HMAC da cadeia de hash de `audit_logs` (ver `application/audit/`). + /// + /// Deliberadamente um segredo PRÓPRIO, não derivado de [jwtSecret]: rotacionar + /// o JWT é rotina esperada, mas rotacionar este invalida silenciosamente a + /// verificação de tudo que já foi gravado na trilha. Ver + /// [_resolveAuditChainSecret]. + final String auditChainSecret; + final String? mqttUsername; final String? mqttPassword; final bool mqttUseTls; @@ -25,23 +43,85 @@ class AppConfig { bool get isProduction => appEnv == 'production'; - factory AppConfig.fromEnvironment() { - final appEnv = Platform.environment['APP_ENV'] ?? 'development'; - final jwtSecret = Platform.environment['JWT_SECRET']; - if (jwtSecret == null && appEnv == 'production') { - throw StateError('JWT_SECRET é obrigatório quando APP_ENV=production.'); - } + /// Segredo usado quando `JWT_SECRET` não é informado em desenvolvimento. + /// + /// É público — está neste arquivo versionado — e por isso só pode valer em + /// `development`. [_resolveJwtSecret] recusa promovê-lo a outro ambiente. + static const developmentJwtSecret = 'development-secret'; + + /// Segredo usado quando `AUDIT_CHAIN_SECRET` não é informado em + /// desenvolvimento. Mesma regra de [developmentJwtSecret]: público, e por + /// isso restrito a `development` por [_resolveAuditChainSecret]. + static const developmentAuditChainSecret = 'development-audit-chain-secret'; + + factory AppConfig.fromEnvironment() => + AppConfig.fromMap(Platform.environment); + + /// Mesma leitura, a partir de um mapa qualquer. + /// + /// `Platform.environment` não é substituível dentro do processo, então as + /// regras de validação só são testáveis por aqui. `fromEnvironment()` é um + /// atalho para o ambiente real. + factory AppConfig.fromMap(Map environment) { + final appEnv = environment['APP_ENV'] ?? 'development'; return AppConfig( - databaseUrl: Platform.environment['DATABASE_URL'] ?? 'postgresql://sinalacs_user:strongpassword@localhost:5432/sinalacs_db', - mqttBroker: Platform.environment['MQTT_BROKER'] ?? 'localhost:1883', - jwtSecret: jwtSecret ?? 'development-secret', - mqttUsername: Platform.environment['MQTT_USERNAME'], - mqttPassword: Platform.environment['MQTT_PASSWORD'], - mqttUseTls: Platform.environment['MQTT_USE_TLS'] == 'true', - mqttCaCertificatePath: Platform.environment['MQTT_CA_CERT_PATH'], + mqttBroker: environment['MQTT_BROKER'] ?? 'localhost:1883', + jwtSecret: _resolveSecret( + value: environment['JWT_SECRET'], + appEnv: appEnv, + envVarName: 'JWT_SECRET', + developmentFallback: developmentJwtSecret, + ), + auditChainSecret: _resolveSecret( + value: environment['AUDIT_CHAIN_SECRET'], + appEnv: appEnv, + envVarName: 'AUDIT_CHAIN_SECRET', + developmentFallback: developmentAuditChainSecret, + ), + mqttUsername: environment['MQTT_USERNAME'], + mqttPassword: environment['MQTT_PASSWORD'], + mqttUseTls: environment['MQTT_USE_TLS'] == 'true', + mqttCaCertificatePath: environment['MQTT_CA_CERT_PATH'], appEnv: appEnv, - enableDevLogin: Platform.environment['ENABLE_DEV_LOGIN'] == 'true', + enableDevLogin: environment['ENABLE_DEV_LOGIN'] == 'true', ); } + + /// Decide um segredo de assinatura, recusando subir com um valor fraco. + /// + /// Regra comum a `JWT_SECRET` e `AUDIT_CHAIN_SECRET`: só `development` aceita + /// ausência da variável. Fora dele a falha é no boot, e não na primeira + /// requisição — um servidor que sobe assinando com um segredo público é pior + /// do que um servidor que não sobe. + /// + /// A checagem anterior (antes de existir mais de um segredo) cobria apenas + /// `appEnv == 'production'` e testava só `== null`, então `JWT_SECRET=""` e + /// `APP_ENV=staging` passavam direto. + static String _resolveSecret({ + required String? value, + required String appEnv, + required String envVarName, + required String developmentFallback, + }) { + final secret = value?.trim(); + final isDevelopment = appEnv == 'development'; + + if (secret == null || secret.isEmpty) { + if (isDevelopment) return developmentFallback; + throw StateError( + '$envVarName é obrigatório quando APP_ENV=$appEnv. ' + 'Gere um com: openssl rand -hex 32', + ); + } + + if (secret == developmentFallback && !isDevelopment) { + throw StateError( + '$envVarName está usando o valor de desenvolvimento, que é público, ' + 'com APP_ENV=$appEnv. Gere um próprio com: openssl rand -hex 32', + ); + } + + return secret; + } } diff --git a/backend/sinalacs_server/lib/src/endpoints/patients_endpoint.dart b/backend/sinalacs_server/lib/src/endpoints/patients_endpoint.dart new file mode 100644 index 0000000..2e04160 --- /dev/null +++ b/backend/sinalacs_server/lib/src/endpoints/patients_endpoint.dart @@ -0,0 +1,30 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/runtime/alert_runtime.dart'; + +/// Diretório de pacientes da microárea do ACS. +/// +/// Existe para a visita de rotina: o único produtor de alertas +/// (`alerts.createRedAlert`) publica só `riskLevel: 'red'` — emergência com +/// SAMU —, e sem esta lista não havia como o ACS escolher um paciente para +/// visitar fora do caminho reativo. +class PatientsEndpoint extends Endpoint { + @override + bool get requireLogin => false; + + Future> listMicroArea( + Session session, { + required String accessToken, + }) async { + final user = AlertRuntime.instance.auth.verifyToken(accessToken); + if (user == null) { + throw AlertPermissionException(message: 'token inválido ou expirado'); + } + + try { + return await AlertRuntime.instance.patientDirectoryServiceFor(session).listForAcs(user); + } on StateError catch (error) { + throw AlertPermissionException(message: error.message); + } + } +} diff --git a/backend/sinalacs_server/lib/src/endpoints/visits_endpoint.dart b/backend/sinalacs_server/lib/src/endpoints/visits_endpoint.dart new file mode 100644 index 0000000..84407b4 --- /dev/null +++ b/backend/sinalacs_server/lib/src/endpoints/visits_endpoint.dart @@ -0,0 +1,48 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/auth/development_auth_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/runtime/alert_runtime.dart'; + +/// Sincronização das visitas domiciliares registradas offline. +/// +/// É a contraparte da fila offline do app do ACS: o dispositivo grava a visita +/// localmente durante a visita (onde normalmente não há rede) e envia o lote +/// quando a conexão volta. +/// +/// O lote inteiro roda em uma transação: ou todas as visitas são aplicadas, ou +/// nenhuma. Um resultado parcial deixaria o dispositivo sem saber o que +/// reenviar. +class VisitsEndpoint extends Endpoint { + @override + bool get requireLogin => false; + + Future> sync( + Session session, { + required String accessToken, + required List visits, + }) async { + final user = _authenticate(accessToken); + + if (visits.isEmpty) return []; + + try { + return await session.db.transaction((transaction) async { + final service = + AlertRuntime.instance.visitSyncServiceFor(session, transaction: transaction); + return service.sync(user: user, entries: visits); + }); + } on ArgumentError catch (error) { + throw AlertValidationException(message: '${error.message}'); + } on StateError catch (error) { + throw AlertPermissionException(message: error.message); + } + } + + AuthenticatedUser _authenticate(String accessToken) { + final user = AlertRuntime.instance.auth.verifyToken(accessToken); + if (user == null) { + throw AlertPermissionException(message: 'token inválido ou expirado'); + } + return user; + } +} diff --git a/backend/sinalacs_server/lib/src/generated/api/micro_area_patient.dart b/backend/sinalacs_server/lib/src/generated/api/micro_area_patient.dart new file mode 100644 index 0000000..0cf21eb --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/api/micro_area_patient.dart @@ -0,0 +1,127 @@ +/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */ +/* To generate run: "serverpod generate" */ + +// ignore_for_file: implementation_imports +// ignore_for_file: library_private_types_in_public_api +// ignore_for_file: non_constant_identifier_names +// ignore_for_file: public_member_api_docs +// ignore_for_file: type_literal_in_constant_pattern +// ignore_for_file: use_super_parameters +// ignore_for_file: invalid_use_of_internal_member + +// ignore_for_file: no_leading_underscores_for_library_prefixes + +import 'package:serverpod/serverpod.dart' as _i1; +import 'package:sinalacs_server/src/generated/protocol.dart' as _i2; + +/// Um paciente da microárea do ACS, para escolher em quem registrar uma visita +/// de rotina. +/// +/// Minimização (LGPD §5.6, spec/lgpd_design.md:364): só o que uma visita de +/// rotina precisa para escolher o paciente certo — nome e condições crônicas. +/// `emergencyContact` fica de fora de propósito: não ajuda a escolher quem +/// visitar. Não existe campo de endereço porque `Patient` não tem essa coluna. +abstract class MicroAreaPatient + implements _i1.SerializableModel, _i1.ProtocolSerialization { + MicroAreaPatient._({ + required this.patientId, + required this.name, + required this.isChronic, + required this.chronicConditions, + }); + + factory MicroAreaPatient({ + required String patientId, + required String name, + required bool isChronic, + required List chronicConditions, + }) = _MicroAreaPatientImpl; + + factory MicroAreaPatient.fromJson(Map jsonSerialization) { + return MicroAreaPatient( + patientId: jsonSerialization['patientId'] as String, + name: jsonSerialization['name'] as String, + isChronic: _i1.BoolJsonExtension.fromJson(jsonSerialization['isChronic']), + chronicConditions: _i2.Protocol().deserialize>( + jsonSerialization['chronicConditions'], + ), + ); + } + + String patientId; + + String name; + + bool isChronic; + + List chronicConditions; + + /// Returns a shallow copy of this [MicroAreaPatient] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + MicroAreaPatient copyWith({ + String? patientId, + String? name, + bool? isChronic, + List? chronicConditions, + }); + @override + Map toJson() { + return { + '__className__': 'MicroAreaPatient', + 'patientId': patientId, + 'name': name, + 'isChronic': isChronic, + 'chronicConditions': chronicConditions.toJson(), + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'MicroAreaPatient', + 'patientId': patientId, + 'name': name, + 'isChronic': isChronic, + 'chronicConditions': chronicConditions.toJson(), + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _MicroAreaPatientImpl extends MicroAreaPatient { + _MicroAreaPatientImpl({ + required String patientId, + required String name, + required bool isChronic, + required List chronicConditions, + }) : super._( + patientId: patientId, + name: name, + isChronic: isChronic, + chronicConditions: chronicConditions, + ); + + /// Returns a shallow copy of this [MicroAreaPatient] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + MicroAreaPatient copyWith({ + String? patientId, + String? name, + bool? isChronic, + List? chronicConditions, + }) { + return MicroAreaPatient( + patientId: patientId ?? this.patientId, + name: name ?? this.name, + isChronic: isChronic ?? this.isChronic, + chronicConditions: + chronicConditions ?? this.chronicConditions.map((e0) => e0).toList(), + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/api/visit_sync_entry.dart b/backend/sinalacs_server/lib/src/generated/api/visit_sync_entry.dart new file mode 100644 index 0000000..056a525 --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/api/visit_sync_entry.dart @@ -0,0 +1,213 @@ +/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */ +/* To generate run: "serverpod generate" */ + +// ignore_for_file: implementation_imports +// ignore_for_file: library_private_types_in_public_api +// ignore_for_file: non_constant_identifier_names +// ignore_for_file: public_member_api_docs +// ignore_for_file: type_literal_in_constant_pattern +// ignore_for_file: use_super_parameters +// ignore_for_file: invalid_use_of_internal_member + +// ignore_for_file: no_leading_underscores_for_library_prefixes + +import 'package:serverpod/serverpod.dart' as _i1; +import '../enums/risk_level.dart' as _i2; +import 'package:sinalacs_server/src/generated/protocol.dart' as _i3; + +/// Uma visita registrada offline, enviada pelo app do ACS para sincronização. +/// +/// `localId` é gerado no dispositivo e tem índice único em `visits`: é o que +/// permite reenviar o mesmo lote depois de uma falha de rede sem duplicar a +/// visita. `version` é a versão que o dispositivo conhece — divergir da versão +/// do servidor significa que alguém alterou a visita no meio, e o resultado é +/// conflito, não sobrescrita. +abstract class VisitSyncEntry + implements _i1.SerializableModel, _i1.ProtocolSerialization { + VisitSyncEntry._({ + required this.localId, + required this.patientId, + required this.scheduledAt, + this.completedAt, + required this.status, + required this.riskLevelBefore, + this.riskLevelAfter, + required this.notes, + required this.version, + }); + + factory VisitSyncEntry({ + required String localId, + required String patientId, + required DateTime scheduledAt, + DateTime? completedAt, + required String status, + required _i2.RiskLevel riskLevelBefore, + _i2.RiskLevel? riskLevelAfter, + required Map notes, + required int version, + }) = _VisitSyncEntryImpl; + + factory VisitSyncEntry.fromJson(Map jsonSerialization) { + return VisitSyncEntry( + localId: jsonSerialization['localId'] as String, + patientId: jsonSerialization['patientId'] as String, + scheduledAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['scheduledAt'], + ), + completedAt: jsonSerialization['completedAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['completedAt'], + ), + status: jsonSerialization['status'] as String, + riskLevelBefore: _i2.RiskLevel.fromJson( + (jsonSerialization['riskLevelBefore'] as String), + ), + riskLevelAfter: jsonSerialization['riskLevelAfter'] == null + ? null + : _i2.RiskLevel.fromJson( + (jsonSerialization['riskLevelAfter'] as String), + ), + notes: _i3.Protocol().deserialize>( + jsonSerialization['notes'], + ), + version: jsonSerialization['version'] as int, + ); + } + + String localId; + + String patientId; + + DateTime scheduledAt; + + DateTime? completedAt; + + String status; + + _i2.RiskLevel riskLevelBefore; + + _i2.RiskLevel? riskLevelAfter; + + Map notes; + + int version; + + /// Returns a shallow copy of this [VisitSyncEntry] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + VisitSyncEntry copyWith({ + String? localId, + String? patientId, + DateTime? scheduledAt, + DateTime? completedAt, + String? status, + _i2.RiskLevel? riskLevelBefore, + _i2.RiskLevel? riskLevelAfter, + Map? notes, + int? version, + }); + @override + Map toJson() { + return { + '__className__': 'VisitSyncEntry', + 'localId': localId, + 'patientId': patientId, + 'scheduledAt': scheduledAt.toJson(), + if (completedAt != null) 'completedAt': completedAt?.toJson(), + 'status': status, + 'riskLevelBefore': riskLevelBefore.toJson(), + if (riskLevelAfter != null) 'riskLevelAfter': riskLevelAfter?.toJson(), + 'notes': notes.toJson(), + 'version': version, + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'VisitSyncEntry', + 'localId': localId, + 'patientId': patientId, + 'scheduledAt': scheduledAt.toJson(), + if (completedAt != null) 'completedAt': completedAt?.toJson(), + 'status': status, + 'riskLevelBefore': riskLevelBefore.toJson(), + if (riskLevelAfter != null) 'riskLevelAfter': riskLevelAfter?.toJson(), + 'notes': notes.toJson(), + 'version': version, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _VisitSyncEntryImpl extends VisitSyncEntry { + _VisitSyncEntryImpl({ + required String localId, + required String patientId, + required DateTime scheduledAt, + DateTime? completedAt, + required String status, + required _i2.RiskLevel riskLevelBefore, + _i2.RiskLevel? riskLevelAfter, + required Map notes, + required int version, + }) : super._( + localId: localId, + patientId: patientId, + scheduledAt: scheduledAt, + completedAt: completedAt, + status: status, + riskLevelBefore: riskLevelBefore, + riskLevelAfter: riskLevelAfter, + notes: notes, + version: version, + ); + + /// Returns a shallow copy of this [VisitSyncEntry] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + VisitSyncEntry copyWith({ + String? localId, + String? patientId, + DateTime? scheduledAt, + Object? completedAt = _Undefined, + String? status, + _i2.RiskLevel? riskLevelBefore, + Object? riskLevelAfter = _Undefined, + Map? notes, + int? version, + }) { + return VisitSyncEntry( + localId: localId ?? this.localId, + patientId: patientId ?? this.patientId, + scheduledAt: scheduledAt ?? this.scheduledAt, + completedAt: completedAt is DateTime? ? completedAt : this.completedAt, + status: status ?? this.status, + riskLevelBefore: riskLevelBefore ?? this.riskLevelBefore, + riskLevelAfter: riskLevelAfter is _i2.RiskLevel? + ? riskLevelAfter + : this.riskLevelAfter, + notes: + notes ?? + this.notes.map( + ( + key0, + value0, + ) => MapEntry( + key0, + value0, + ), + ), + version: version ?? this.version, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/api/visit_sync_result.dart b/backend/sinalacs_server/lib/src/generated/api/visit_sync_result.dart new file mode 100644 index 0000000..e7f8f36 --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/api/visit_sync_result.dart @@ -0,0 +1,129 @@ +/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */ +/* To generate run: "serverpod generate" */ + +// ignore_for_file: implementation_imports +// ignore_for_file: library_private_types_in_public_api +// ignore_for_file: non_constant_identifier_names +// ignore_for_file: public_member_api_docs +// ignore_for_file: type_literal_in_constant_pattern +// ignore_for_file: use_super_parameters +// ignore_for_file: invalid_use_of_internal_member + +// ignore_for_file: no_leading_underscores_for_library_prefixes + +import 'package:serverpod/serverpod.dart' as _i1; +import '../enums/sync_status.dart' as _i2; + +/// Resultado da sincronização de UMA visita. +/// +/// O app casa pelo `localId`, não pela posição na lista: um resultado ausente +/// deixa a visita pendente para a próxima tentativa, em vez de dá-la por +/// sincronizada. +abstract class VisitSyncResult + implements _i1.SerializableModel, _i1.ProtocolSerialization { + VisitSyncResult._({ + required this.localId, + required this.syncStatus, + this.serverVersion, + this.message, + }); + + factory VisitSyncResult({ + required String localId, + required _i2.SyncStatus syncStatus, + int? serverVersion, + String? message, + }) = _VisitSyncResultImpl; + + factory VisitSyncResult.fromJson(Map jsonSerialization) { + return VisitSyncResult( + localId: jsonSerialization['localId'] as String, + syncStatus: _i2.SyncStatus.fromJson( + (jsonSerialization['syncStatus'] as String), + ), + serverVersion: jsonSerialization['serverVersion'] as int?, + message: jsonSerialization['message'] as String?, + ); + } + + String localId; + + _i2.SyncStatus syncStatus; + + /// Versão gravada no servidor. Em conflito, é a versão que o dispositivo + /// precisa reconciliar antes de reenviar. + int? serverVersion; + + /// Preenchido apenas quando syncStatus é error. + String? message; + + /// Returns a shallow copy of this [VisitSyncResult] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + VisitSyncResult copyWith({ + String? localId, + _i2.SyncStatus? syncStatus, + int? serverVersion, + String? message, + }); + @override + Map toJson() { + return { + '__className__': 'VisitSyncResult', + 'localId': localId, + 'syncStatus': syncStatus.toJson(), + if (serverVersion != null) 'serverVersion': serverVersion, + if (message != null) 'message': message, + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'VisitSyncResult', + 'localId': localId, + 'syncStatus': syncStatus.toJson(), + if (serverVersion != null) 'serverVersion': serverVersion, + if (message != null) 'message': message, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _VisitSyncResultImpl extends VisitSyncResult { + _VisitSyncResultImpl({ + required String localId, + required _i2.SyncStatus syncStatus, + int? serverVersion, + String? message, + }) : super._( + localId: localId, + syncStatus: syncStatus, + serverVersion: serverVersion, + message: message, + ); + + /// Returns a shallow copy of this [VisitSyncResult] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + VisitSyncResult copyWith({ + String? localId, + _i2.SyncStatus? syncStatus, + Object? serverVersion = _Undefined, + Object? message = _Undefined, + }) { + return VisitSyncResult( + localId: localId ?? this.localId, + syncStatus: syncStatus ?? this.syncStatus, + serverVersion: serverVersion is int? ? serverVersion : this.serverVersion, + message: message is String? ? message : this.message, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/audit_log.dart b/backend/sinalacs_server/lib/src/generated/audit_log.dart index 5bcc67b..41c17cb 100644 --- a/backend/sinalacs_server/lib/src/generated/audit_log.dart +++ b/backend/sinalacs_server/lib/src/generated/audit_log.dart @@ -14,6 +14,16 @@ import 'package:serverpod/serverpod.dart' as _i1; /// Trilha de auditoria de acesso a dados sensíveis. Append-only. +/// +/// `sequence`/`previousHash`/`entryHash` formam uma cadeia de hash (LGPD-RT03): +/// cada linha encadeia à anterior via `previousHash == entryHash` da linha de +/// `sequence - 1`, e `entryHash` é um HMAC-SHA256 sobre o conteúdo da própria +/// linha (ver `application/audit/audit_chain.dart`). Isso torna qualquer +/// edição, remoção ou reordenação de linha detectável — inclusive por quem tem +/// acesso de escrita direto ao Postgres, que é o adversário que a §458 de +/// spec/lgpd_design.md descreve. O índice único em `sequence` é o segundo +/// cinto: uma bifurcação da cadeia por concorrência estoura na hora em vez de +/// corromper em silêncio. abstract class AuditLog implements _i1.TableRow<_i1.UuidValue?>, _i1.ProtocolSerialization { AuditLog._({ @@ -25,6 +35,9 @@ abstract class AuditLog required this.timestamp, required this.ipHash, required this.result, + required this.sequence, + required this.previousHash, + required this.entryHash, }); factory AuditLog({ @@ -36,6 +49,9 @@ abstract class AuditLog required DateTime timestamp, required String ipHash, required String result, + required int sequence, + required String previousHash, + required String entryHash, }) = _AuditLogImpl; factory AuditLog.fromJson(Map jsonSerialization) { @@ -56,6 +72,9 @@ abstract class AuditLog ), ipHash: jsonSerialization['ipHash'] as String, result: jsonSerialization['result'] as String, + sequence: jsonSerialization['sequence'] as int, + previousHash: jsonSerialization['previousHash'] as String, + entryHash: jsonSerialization['entryHash'] as String, ); } @@ -80,6 +99,18 @@ abstract class AuditLog String result; + /// Posição na cadeia, começando em 1. Contígua por construção — um buraco + /// aqui é uma linha apagada. + int sequence; + + /// `entryHash` da linha anterior, ou `AuditChain.genesisHash` (64 zeros) na + /// primeira linha. Nunca nulo: gênese e "escrito antes da cadeia existir" + /// não podem ter a mesma representação. + String previousHash; + + /// HMAC-SHA256(AUDIT_CHAIN_SECRET, conteúdo da linha). Ver AuditChain.compute. + String entryHash; + @override _i1.Table<_i1.UuidValue?> get table => t; @@ -95,6 +126,9 @@ abstract class AuditLog DateTime? timestamp, String? ipHash, String? result, + int? sequence, + String? previousHash, + String? entryHash, }); @override Map toJson() { @@ -108,6 +142,9 @@ abstract class AuditLog 'timestamp': timestamp.toJson(), 'ipHash': ipHash, 'result': result, + 'sequence': sequence, + 'previousHash': previousHash, + 'entryHash': entryHash, }; } @@ -123,6 +160,9 @@ abstract class AuditLog 'timestamp': timestamp.toJson(), 'ipHash': ipHash, 'result': result, + 'sequence': sequence, + 'previousHash': previousHash, + 'entryHash': entryHash, }; } @@ -168,6 +208,9 @@ class _AuditLogImpl extends AuditLog { required DateTime timestamp, required String ipHash, required String result, + required int sequence, + required String previousHash, + required String entryHash, }) : super._( id: id, userId: userId, @@ -177,6 +220,9 @@ class _AuditLogImpl extends AuditLog { timestamp: timestamp, ipHash: ipHash, result: result, + sequence: sequence, + previousHash: previousHash, + entryHash: entryHash, ); /// Returns a shallow copy of this [AuditLog] @@ -192,6 +238,9 @@ class _AuditLogImpl extends AuditLog { DateTime? timestamp, String? ipHash, String? result, + int? sequence, + String? previousHash, + String? entryHash, }) { return AuditLog( id: id is _i1.UuidValue? ? id : this.id, @@ -202,6 +251,9 @@ class _AuditLogImpl extends AuditLog { timestamp: timestamp ?? this.timestamp, ipHash: ipHash ?? this.ipHash, result: result ?? this.result, + sequence: sequence ?? this.sequence, + previousHash: previousHash ?? this.previousHash, + entryHash: entryHash ?? this.entryHash, ); } } @@ -247,6 +299,21 @@ class AuditLogUpdateTable extends _i1.UpdateTable { table.result, value, ); + + _i1.ColumnValue sequence(int value) => _i1.ColumnValue( + table.sequence, + value, + ); + + _i1.ColumnValue previousHash(String value) => _i1.ColumnValue( + table.previousHash, + value, + ); + + _i1.ColumnValue entryHash(String value) => _i1.ColumnValue( + table.entryHash, + value, + ); } class AuditLogTable extends _i1.Table<_i1.UuidValue?> { @@ -280,6 +347,18 @@ class AuditLogTable extends _i1.Table<_i1.UuidValue?> { 'result', this, ); + sequence = _i1.ColumnInt( + 'sequence', + this, + ); + previousHash = _i1.ColumnString( + 'previousHash', + this, + ); + entryHash = _i1.ColumnString( + 'entryHash', + this, + ); } late final AuditLogUpdateTable updateTable; @@ -298,6 +377,18 @@ class AuditLogTable extends _i1.Table<_i1.UuidValue?> { late final _i1.ColumnString result; + /// Posição na cadeia, começando em 1. Contígua por construção — um buraco + /// aqui é uma linha apagada. + late final _i1.ColumnInt sequence; + + /// `entryHash` da linha anterior, ou `AuditChain.genesisHash` (64 zeros) na + /// primeira linha. Nunca nulo: gênese e "escrito antes da cadeia existir" + /// não podem ter a mesma representação. + late final _i1.ColumnString previousHash; + + /// HMAC-SHA256(AUDIT_CHAIN_SECRET, conteúdo da linha). Ver AuditChain.compute. + late final _i1.ColumnString entryHash; + @override List<_i1.Column> get columns => [ id, @@ -308,6 +399,9 @@ class AuditLogTable extends _i1.Table<_i1.UuidValue?> { timestamp, ipHash, result, + sequence, + previousHash, + entryHash, ]; } diff --git a/backend/sinalacs_server/lib/src/generated/endpoints.dart b/backend/sinalacs_server/lib/src/generated/endpoints.dart index 86eb697..efdf64e 100644 --- a/backend/sinalacs_server/lib/src/generated/endpoints.dart +++ b/backend/sinalacs_server/lib/src/generated/endpoints.dart @@ -15,7 +15,10 @@ import 'package:serverpod/serverpod.dart' as _i1; import '../endpoints/alerts_endpoint.dart' as _i2; import '../endpoints/auth_endpoint.dart' as _i3; import '../endpoints/health_endpoint.dart' as _i4; -import '../endpoints/triage_endpoint.dart' as _i5; +import '../endpoints/patients_endpoint.dart' as _i5; +import '../endpoints/triage_endpoint.dart' as _i6; +import '../endpoints/visits_endpoint.dart' as _i7; +import 'package:sinalacs_server/src/generated/api/visit_sync_entry.dart' as _i8; class Endpoints extends _i1.EndpointDispatch { @override @@ -39,12 +42,24 @@ class Endpoints extends _i1.EndpointDispatch { 'health', null, ), - 'triage': _i5.TriageEndpoint() + 'patients': _i5.PatientsEndpoint() + ..initialize( + server, + 'patients', + null, + ), + 'triage': _i6.TriageEndpoint() ..initialize( server, 'triage', null, ), + 'visits': _i7.VisitsEndpoint() + ..initialize( + server, + 'visits', + null, + ), }; connectors['alerts'] = _i1.EndpointConnector( name: 'alerts', @@ -149,6 +164,31 @@ class Endpoints extends _i1.EndpointDispatch { ), }, ); + connectors['patients'] = _i1.EndpointConnector( + name: 'patients', + endpoint: endpoints['patients']!, + methodConnectors: { + 'listMicroArea': _i1.MethodConnector( + name: 'listMicroArea', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => + (endpoints['patients'] as _i5.PatientsEndpoint).listMicroArea( + session, + accessToken: params['accessToken'], + ), + ), + }, + ); connectors['triage'] = _i1.EndpointConnector( name: 'triage', endpoint: endpoints['triage']!, @@ -191,7 +231,7 @@ class Endpoints extends _i1.EndpointDispatch { ( _i1.Session session, Map params, - ) async => (endpoints['triage'] as _i5.TriageEndpoint).evaluate( + ) async => (endpoints['triage'] as _i6.TriageEndpoint).evaluate( session, chestPain: params['chestPain'], difficultyBreathing: params['difficultyBreathing'], @@ -203,5 +243,35 @@ class Endpoints extends _i1.EndpointDispatch { ), }, ); + connectors['visits'] = _i1.EndpointConnector( + name: 'visits', + endpoint: endpoints['visits']!, + methodConnectors: { + 'sync': _i1.MethodConnector( + name: 'sync', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + 'visits': _i1.ParameterDescription( + name: 'visits', + type: _i1.getType>(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => (endpoints['visits'] as _i7.VisitsEndpoint).sync( + session, + accessToken: params['accessToken'], + visits: params['visits'], + ), + ), + }, + ); } } diff --git a/backend/sinalacs_server/lib/src/generated/enums/sync_status.dart b/backend/sinalacs_server/lib/src/generated/enums/sync_status.dart index 53f2f07..7f94ea4 100644 --- a/backend/sinalacs_server/lib/src/generated/enums/sync_status.dart +++ b/backend/sinalacs_server/lib/src/generated/enums/sync_status.dart @@ -14,11 +14,18 @@ import 'package:serverpod/serverpod.dart' as _i1; /// Estado de sincronização offline-first, espelha a SyncFsm. +/// +/// `rejected` é TERMINAL: ao contrário de `error`, que é retentável (o +/// dispositivo tenta de novo mais tarde), uma visita `rejected` nunca vai dar +/// certo numa próxima tentativa — o motivo não muda com o tempo (ex.: paciente +/// fora da microárea do ACS, identificador malformado). `SyncFsm.rejected` não +/// tem transição de saída; `networkUp`/`syncStart` não o alcançam. enum SyncStatus implements _i1.SerializableModel { pending, synced, conflict, - error; + error, + rejected; static SyncStatus fromJson(String name) { switch (name) { @@ -30,6 +37,8 @@ enum SyncStatus implements _i1.SerializableModel { return SyncStatus.conflict; case 'error': return SyncStatus.error; + case 'rejected': + return SyncStatus.rejected; default: throw ArgumentError( 'Value "$name" cannot be converted to "SyncStatus"', diff --git a/backend/sinalacs_server/lib/src/generated/protocol.dart b/backend/sinalacs_server/lib/src/generated/protocol.dart index 05d40e3..509ba81 100644 --- a/backend/sinalacs_server/lib/src/generated/protocol.dart +++ b/backend/sinalacs_server/lib/src/generated/protocol.dart @@ -20,26 +20,35 @@ import 'alert_idempotency_key.dart' as _i6; import 'alert_outbox_entry.dart' as _i7; import 'api/alert_ack_result.dart' as _i8; import 'api/development_login_result.dart' as _i9; -import 'api/red_alert_result.dart' as _i10; -import 'api/service_health.dart' as _i11; -import 'api/triage_result.dart' as _i12; -import 'audit_log.dart' as _i13; -import 'consent_log.dart' as _i14; -import 'enums/alert_status.dart' as _i15; -import 'enums/risk_level.dart' as _i16; -import 'enums/sync_status.dart' as _i17; -import 'enums/user_role.dart' as _i18; -import 'exceptions/alert_dispatch_unavailable_exception.dart' as _i19; -import 'exceptions/alert_permission_exception.dart' as _i20; -import 'exceptions/alert_validation_exception.dart' as _i21; -import 'exceptions/endpoint_disabled_exception.dart' as _i22; -import 'micro_area.dart' as _i23; -import 'patient.dart' as _i24; -import 'triage_answer.dart' as _i25; -import 'triage_session.dart' as _i26; -import 'ubs.dart' as _i27; -import 'user.dart' as _i28; -import 'visit.dart' as _i29; +import 'api/micro_area_patient.dart' as _i10; +import 'api/red_alert_result.dart' as _i11; +import 'api/service_health.dart' as _i12; +import 'api/triage_result.dart' as _i13; +import 'api/visit_sync_entry.dart' as _i14; +import 'api/visit_sync_result.dart' as _i15; +import 'audit_log.dart' as _i16; +import 'consent_log.dart' as _i17; +import 'enums/alert_status.dart' as _i18; +import 'enums/risk_level.dart' as _i19; +import 'enums/sync_status.dart' as _i20; +import 'enums/user_role.dart' as _i21; +import 'exceptions/alert_dispatch_unavailable_exception.dart' as _i22; +import 'exceptions/alert_permission_exception.dart' as _i23; +import 'exceptions/alert_validation_exception.dart' as _i24; +import 'exceptions/endpoint_disabled_exception.dart' as _i25; +import 'micro_area.dart' as _i26; +import 'patient.dart' as _i27; +import 'triage_answer.dart' as _i28; +import 'triage_session.dart' as _i29; +import 'ubs.dart' as _i30; +import 'user.dart' as _i31; +import 'visit.dart' as _i32; +import 'package:sinalacs_server/src/generated/api/micro_area_patient.dart' + as _i33; +import 'package:sinalacs_server/src/generated/api/visit_sync_result.dart' + as _i34; +import 'package:sinalacs_server/src/generated/api/visit_sync_entry.dart' + as _i35; export 'acs.dart'; export 'alert.dart'; export 'alert_delivery_record.dart'; @@ -47,9 +56,12 @@ export 'alert_idempotency_key.dart'; export 'alert_outbox_entry.dart'; export 'api/alert_ack_result.dart'; export 'api/development_login_result.dart'; +export 'api/micro_area_patient.dart'; export 'api/red_alert_result.dart'; export 'api/service_health.dart'; export 'api/triage_result.dart'; +export 'api/visit_sync_entry.dart'; +export 'api/visit_sync_result.dart'; export 'audit_log.dart'; export 'consent_log.dart'; export 'enums/alert_status.dart'; @@ -634,6 +646,24 @@ class Protocol extends _i1.SerializationManagerServer { isNullable: false, dartType: 'String', ), + _i2.ColumnDefinition( + name: 'sequence', + columnType: _i2.ColumnType.bigint, + isNullable: false, + dartType: 'int', + ), + _i2.ColumnDefinition( + name: 'previousHash', + columnType: _i2.ColumnType.text, + isNullable: false, + dartType: 'String', + ), + _i2.ColumnDefinition( + name: 'entryHash', + columnType: _i2.ColumnType.text, + isNullable: false, + dartType: 'String', + ), ], foreignKeys: [ _i2.ForeignKeyDefinition( @@ -661,6 +691,19 @@ class Protocol extends _i1.SerializationManagerServer { isUnique: true, isPrimary: true, ), + _i2.IndexDefinition( + indexName: 'audit_logs_sequence_idx', + tableSpace: null, + elements: [ + _i2.IndexElementDefinition( + type: _i2.IndexElementDefinitionType.column, + definition: 'sequence', + ), + ], + type: 'btree', + isUnique: true, + isPrimary: false, + ), ], managed: true, ), @@ -1310,65 +1353,74 @@ class Protocol extends _i1.SerializationManagerServer { if (t == _i9.DevelopmentLoginResult) { return _i9.DevelopmentLoginResult.fromJson(data) as T; } - if (t == _i10.RedAlertResult) { - return _i10.RedAlertResult.fromJson(data) as T; + if (t == _i10.MicroAreaPatient) { + return _i10.MicroAreaPatient.fromJson(data) as T; + } + if (t == _i11.RedAlertResult) { + return _i11.RedAlertResult.fromJson(data) as T; + } + if (t == _i12.ServiceHealth) { + return _i12.ServiceHealth.fromJson(data) as T; } - if (t == _i11.ServiceHealth) { - return _i11.ServiceHealth.fromJson(data) as T; + if (t == _i13.TriageResult) { + return _i13.TriageResult.fromJson(data) as T; } - if (t == _i12.TriageResult) { - return _i12.TriageResult.fromJson(data) as T; + if (t == _i14.VisitSyncEntry) { + return _i14.VisitSyncEntry.fromJson(data) as T; } - if (t == _i13.AuditLog) { - return _i13.AuditLog.fromJson(data) as T; + if (t == _i15.VisitSyncResult) { + return _i15.VisitSyncResult.fromJson(data) as T; } - if (t == _i14.ConsentLog) { - return _i14.ConsentLog.fromJson(data) as T; + if (t == _i16.AuditLog) { + return _i16.AuditLog.fromJson(data) as T; } - if (t == _i15.AlertStatus) { - return _i15.AlertStatus.fromJson(data) as T; + if (t == _i17.ConsentLog) { + return _i17.ConsentLog.fromJson(data) as T; } - if (t == _i16.RiskLevel) { - return _i16.RiskLevel.fromJson(data) as T; + if (t == _i18.AlertStatus) { + return _i18.AlertStatus.fromJson(data) as T; } - if (t == _i17.SyncStatus) { - return _i17.SyncStatus.fromJson(data) as T; + if (t == _i19.RiskLevel) { + return _i19.RiskLevel.fromJson(data) as T; } - if (t == _i18.UserRole) { - return _i18.UserRole.fromJson(data) as T; + if (t == _i20.SyncStatus) { + return _i20.SyncStatus.fromJson(data) as T; } - if (t == _i19.AlertDispatchUnavailableException) { - return _i19.AlertDispatchUnavailableException.fromJson(data) as T; + if (t == _i21.UserRole) { + return _i21.UserRole.fromJson(data) as T; } - if (t == _i20.AlertPermissionException) { - return _i20.AlertPermissionException.fromJson(data) as T; + if (t == _i22.AlertDispatchUnavailableException) { + return _i22.AlertDispatchUnavailableException.fromJson(data) as T; } - if (t == _i21.AlertValidationException) { - return _i21.AlertValidationException.fromJson(data) as T; + if (t == _i23.AlertPermissionException) { + return _i23.AlertPermissionException.fromJson(data) as T; } - if (t == _i22.EndpointDisabledException) { - return _i22.EndpointDisabledException.fromJson(data) as T; + if (t == _i24.AlertValidationException) { + return _i24.AlertValidationException.fromJson(data) as T; } - if (t == _i23.MicroArea) { - return _i23.MicroArea.fromJson(data) as T; + if (t == _i25.EndpointDisabledException) { + return _i25.EndpointDisabledException.fromJson(data) as T; } - if (t == _i24.Patient) { - return _i24.Patient.fromJson(data) as T; + if (t == _i26.MicroArea) { + return _i26.MicroArea.fromJson(data) as T; } - if (t == _i25.TriageAnswer) { - return _i25.TriageAnswer.fromJson(data) as T; + if (t == _i27.Patient) { + return _i27.Patient.fromJson(data) as T; } - if (t == _i26.TriageSession) { - return _i26.TriageSession.fromJson(data) as T; + if (t == _i28.TriageAnswer) { + return _i28.TriageAnswer.fromJson(data) as T; } - if (t == _i27.Ubs) { - return _i27.Ubs.fromJson(data) as T; + if (t == _i29.TriageSession) { + return _i29.TriageSession.fromJson(data) as T; } - if (t == _i28.User) { - return _i28.User.fromJson(data) as T; + if (t == _i30.Ubs) { + return _i30.Ubs.fromJson(data) as T; } - if (t == _i29.Visit) { - return _i29.Visit.fromJson(data) as T; + if (t == _i31.User) { + return _i31.User.fromJson(data) as T; + } + if (t == _i32.Visit) { + return _i32.Visit.fromJson(data) as T; } if (t == _i1.getType<_i3.Acs?>()) { return (data != null ? _i3.Acs.fromJson(data) : null) as T; @@ -1394,93 +1446,120 @@ class Protocol extends _i1.SerializationManagerServer { return (data != null ? _i9.DevelopmentLoginResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i10.RedAlertResult?>()) { - return (data != null ? _i10.RedAlertResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i10.MicroAreaPatient?>()) { + return (data != null ? _i10.MicroAreaPatient.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i11.RedAlertResult?>()) { + return (data != null ? _i11.RedAlertResult.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i12.ServiceHealth?>()) { + return (data != null ? _i12.ServiceHealth.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i13.TriageResult?>()) { + return (data != null ? _i13.TriageResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i11.ServiceHealth?>()) { - return (data != null ? _i11.ServiceHealth.fromJson(data) : null) as T; + if (t == _i1.getType<_i14.VisitSyncEntry?>()) { + return (data != null ? _i14.VisitSyncEntry.fromJson(data) : null) as T; } - if (t == _i1.getType<_i12.TriageResult?>()) { - return (data != null ? _i12.TriageResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i15.VisitSyncResult?>()) { + return (data != null ? _i15.VisitSyncResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i13.AuditLog?>()) { - return (data != null ? _i13.AuditLog.fromJson(data) : null) as T; + if (t == _i1.getType<_i16.AuditLog?>()) { + return (data != null ? _i16.AuditLog.fromJson(data) : null) as T; } - if (t == _i1.getType<_i14.ConsentLog?>()) { - return (data != null ? _i14.ConsentLog.fromJson(data) : null) as T; + if (t == _i1.getType<_i17.ConsentLog?>()) { + return (data != null ? _i17.ConsentLog.fromJson(data) : null) as T; } - if (t == _i1.getType<_i15.AlertStatus?>()) { - return (data != null ? _i15.AlertStatus.fromJson(data) : null) as T; + if (t == _i1.getType<_i18.AlertStatus?>()) { + return (data != null ? _i18.AlertStatus.fromJson(data) : null) as T; } - if (t == _i1.getType<_i16.RiskLevel?>()) { - return (data != null ? _i16.RiskLevel.fromJson(data) : null) as T; + if (t == _i1.getType<_i19.RiskLevel?>()) { + return (data != null ? _i19.RiskLevel.fromJson(data) : null) as T; } - if (t == _i1.getType<_i17.SyncStatus?>()) { - return (data != null ? _i17.SyncStatus.fromJson(data) : null) as T; + if (t == _i1.getType<_i20.SyncStatus?>()) { + return (data != null ? _i20.SyncStatus.fromJson(data) : null) as T; } - if (t == _i1.getType<_i18.UserRole?>()) { - return (data != null ? _i18.UserRole.fromJson(data) : null) as T; + if (t == _i1.getType<_i21.UserRole?>()) { + return (data != null ? _i21.UserRole.fromJson(data) : null) as T; } - if (t == _i1.getType<_i19.AlertDispatchUnavailableException?>()) { + if (t == _i1.getType<_i22.AlertDispatchUnavailableException?>()) { return (data != null - ? _i19.AlertDispatchUnavailableException.fromJson(data) + ? _i22.AlertDispatchUnavailableException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i20.AlertPermissionException?>()) { + if (t == _i1.getType<_i23.AlertPermissionException?>()) { return (data != null - ? _i20.AlertPermissionException.fromJson(data) + ? _i23.AlertPermissionException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i21.AlertValidationException?>()) { + if (t == _i1.getType<_i24.AlertValidationException?>()) { return (data != null - ? _i21.AlertValidationException.fromJson(data) + ? _i24.AlertValidationException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i22.EndpointDisabledException?>()) { + if (t == _i1.getType<_i25.EndpointDisabledException?>()) { return (data != null - ? _i22.EndpointDisabledException.fromJson(data) + ? _i25.EndpointDisabledException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i23.MicroArea?>()) { - return (data != null ? _i23.MicroArea.fromJson(data) : null) as T; + if (t == _i1.getType<_i26.MicroArea?>()) { + return (data != null ? _i26.MicroArea.fromJson(data) : null) as T; } - if (t == _i1.getType<_i24.Patient?>()) { - return (data != null ? _i24.Patient.fromJson(data) : null) as T; + if (t == _i1.getType<_i27.Patient?>()) { + return (data != null ? _i27.Patient.fromJson(data) : null) as T; } - if (t == _i1.getType<_i25.TriageAnswer?>()) { - return (data != null ? _i25.TriageAnswer.fromJson(data) : null) as T; + if (t == _i1.getType<_i28.TriageAnswer?>()) { + return (data != null ? _i28.TriageAnswer.fromJson(data) : null) as T; } - if (t == _i1.getType<_i26.TriageSession?>()) { - return (data != null ? _i26.TriageSession.fromJson(data) : null) as T; + if (t == _i1.getType<_i29.TriageSession?>()) { + return (data != null ? _i29.TriageSession.fromJson(data) : null) as T; } - if (t == _i1.getType<_i27.Ubs?>()) { - return (data != null ? _i27.Ubs.fromJson(data) : null) as T; + if (t == _i1.getType<_i30.Ubs?>()) { + return (data != null ? _i30.Ubs.fromJson(data) : null) as T; } - if (t == _i1.getType<_i28.User?>()) { - return (data != null ? _i28.User.fromJson(data) : null) as T; + if (t == _i1.getType<_i31.User?>()) { + return (data != null ? _i31.User.fromJson(data) : null) as T; } - if (t == _i1.getType<_i29.Visit?>()) { - return (data != null ? _i29.Visit.fromJson(data) : null) as T; + if (t == _i1.getType<_i32.Visit?>()) { + return (data != null ? _i32.Visit.fromJson(data) : null) as T; } if (t == List) { return (data as List).map((e) => deserialize(e)).toList() as T; } - if (t == List<_i25.TriageAnswer>) { - return (data as List) - .map((e) => deserialize<_i25.TriageAnswer>(e)) - .toList() - as T; - } if (t == Map) { return (data as Map).map( (k, v) => MapEntry(deserialize(k), deserialize(v)), ) as T; } + if (t == List<_i28.TriageAnswer>) { + return (data as List) + .map((e) => deserialize<_i28.TriageAnswer>(e)) + .toList() + as T; + } + if (t == List<_i33.MicroAreaPatient>) { + return (data as List) + .map((e) => deserialize<_i33.MicroAreaPatient>(e)) + .toList() + as T; + } + if (t == List<_i34.VisitSyncResult>) { + return (data as List) + .map((e) => deserialize<_i34.VisitSyncResult>(e)) + .toList() + as T; + } + if (t == List<_i35.VisitSyncEntry>) { + return (data as List) + .map((e) => deserialize<_i35.VisitSyncEntry>(e)) + .toList() + as T; + } try { return _i2.Protocol().deserialize(data, t); } on _i1.DeserializationTypeNotFoundException catch (_) {} @@ -1496,27 +1575,30 @@ class Protocol extends _i1.SerializationManagerServer { _i7.AlertOutboxEntry => 'AlertOutboxEntry', _i8.AlertAckResult => 'AlertAckResult', _i9.DevelopmentLoginResult => 'DevelopmentLoginResult', - _i10.RedAlertResult => 'RedAlertResult', - _i11.ServiceHealth => 'ServiceHealth', - _i12.TriageResult => 'TriageResult', - _i13.AuditLog => 'AuditLog', - _i14.ConsentLog => 'ConsentLog', - _i15.AlertStatus => 'AlertStatus', - _i16.RiskLevel => 'RiskLevel', - _i17.SyncStatus => 'SyncStatus', - _i18.UserRole => 'UserRole', - _i19.AlertDispatchUnavailableException => + _i10.MicroAreaPatient => 'MicroAreaPatient', + _i11.RedAlertResult => 'RedAlertResult', + _i12.ServiceHealth => 'ServiceHealth', + _i13.TriageResult => 'TriageResult', + _i14.VisitSyncEntry => 'VisitSyncEntry', + _i15.VisitSyncResult => 'VisitSyncResult', + _i16.AuditLog => 'AuditLog', + _i17.ConsentLog => 'ConsentLog', + _i18.AlertStatus => 'AlertStatus', + _i19.RiskLevel => 'RiskLevel', + _i20.SyncStatus => 'SyncStatus', + _i21.UserRole => 'UserRole', + _i22.AlertDispatchUnavailableException => 'AlertDispatchUnavailableException', - _i20.AlertPermissionException => 'AlertPermissionException', - _i21.AlertValidationException => 'AlertValidationException', - _i22.EndpointDisabledException => 'EndpointDisabledException', - _i23.MicroArea => 'MicroArea', - _i24.Patient => 'Patient', - _i25.TriageAnswer => 'TriageAnswer', - _i26.TriageSession => 'TriageSession', - _i27.Ubs => 'Ubs', - _i28.User => 'User', - _i29.Visit => 'Visit', + _i23.AlertPermissionException => 'AlertPermissionException', + _i24.AlertValidationException => 'AlertValidationException', + _i25.EndpointDisabledException => 'EndpointDisabledException', + _i26.MicroArea => 'MicroArea', + _i27.Patient => 'Patient', + _i28.TriageAnswer => 'TriageAnswer', + _i29.TriageSession => 'TriageSession', + _i30.Ubs => 'Ubs', + _i31.User => 'User', + _i32.Visit => 'Visit', _ => null, }; } @@ -1545,45 +1627,51 @@ class Protocol extends _i1.SerializationManagerServer { return 'AlertAckResult'; case _i9.DevelopmentLoginResult(): return 'DevelopmentLoginResult'; - case _i10.RedAlertResult(): + case _i10.MicroAreaPatient(): + return 'MicroAreaPatient'; + case _i11.RedAlertResult(): return 'RedAlertResult'; - case _i11.ServiceHealth(): + case _i12.ServiceHealth(): return 'ServiceHealth'; - case _i12.TriageResult(): + case _i13.TriageResult(): return 'TriageResult'; - case _i13.AuditLog(): + case _i14.VisitSyncEntry(): + return 'VisitSyncEntry'; + case _i15.VisitSyncResult(): + return 'VisitSyncResult'; + case _i16.AuditLog(): return 'AuditLog'; - case _i14.ConsentLog(): + case _i17.ConsentLog(): return 'ConsentLog'; - case _i15.AlertStatus(): + case _i18.AlertStatus(): return 'AlertStatus'; - case _i16.RiskLevel(): + case _i19.RiskLevel(): return 'RiskLevel'; - case _i17.SyncStatus(): + case _i20.SyncStatus(): return 'SyncStatus'; - case _i18.UserRole(): + case _i21.UserRole(): return 'UserRole'; - case _i19.AlertDispatchUnavailableException(): + case _i22.AlertDispatchUnavailableException(): return 'AlertDispatchUnavailableException'; - case _i20.AlertPermissionException(): + case _i23.AlertPermissionException(): return 'AlertPermissionException'; - case _i21.AlertValidationException(): + case _i24.AlertValidationException(): return 'AlertValidationException'; - case _i22.EndpointDisabledException(): + case _i25.EndpointDisabledException(): return 'EndpointDisabledException'; - case _i23.MicroArea(): + case _i26.MicroArea(): return 'MicroArea'; - case _i24.Patient(): + case _i27.Patient(): return 'Patient'; - case _i25.TriageAnswer(): + case _i28.TriageAnswer(): return 'TriageAnswer'; - case _i26.TriageSession(): + case _i29.TriageSession(): return 'TriageSession'; - case _i27.Ubs(): + case _i30.Ubs(): return 'Ubs'; - case _i28.User(): + case _i31.User(): return 'User'; - case _i29.Visit(): + case _i32.Visit(): return 'Visit'; } className = _i2.Protocol().getClassNameForObject(data); @@ -1620,65 +1708,74 @@ class Protocol extends _i1.SerializationManagerServer { if (dataClassName == 'DevelopmentLoginResult') { return deserialize<_i9.DevelopmentLoginResult>(data['data']); } + if (dataClassName == 'MicroAreaPatient') { + return deserialize<_i10.MicroAreaPatient>(data['data']); + } if (dataClassName == 'RedAlertResult') { - return deserialize<_i10.RedAlertResult>(data['data']); + return deserialize<_i11.RedAlertResult>(data['data']); } if (dataClassName == 'ServiceHealth') { - return deserialize<_i11.ServiceHealth>(data['data']); + return deserialize<_i12.ServiceHealth>(data['data']); } if (dataClassName == 'TriageResult') { - return deserialize<_i12.TriageResult>(data['data']); + return deserialize<_i13.TriageResult>(data['data']); + } + if (dataClassName == 'VisitSyncEntry') { + return deserialize<_i14.VisitSyncEntry>(data['data']); + } + if (dataClassName == 'VisitSyncResult') { + return deserialize<_i15.VisitSyncResult>(data['data']); } if (dataClassName == 'AuditLog') { - return deserialize<_i13.AuditLog>(data['data']); + return deserialize<_i16.AuditLog>(data['data']); } if (dataClassName == 'ConsentLog') { - return deserialize<_i14.ConsentLog>(data['data']); + return deserialize<_i17.ConsentLog>(data['data']); } if (dataClassName == 'AlertStatus') { - return deserialize<_i15.AlertStatus>(data['data']); + return deserialize<_i18.AlertStatus>(data['data']); } if (dataClassName == 'RiskLevel') { - return deserialize<_i16.RiskLevel>(data['data']); + return deserialize<_i19.RiskLevel>(data['data']); } if (dataClassName == 'SyncStatus') { - return deserialize<_i17.SyncStatus>(data['data']); + return deserialize<_i20.SyncStatus>(data['data']); } if (dataClassName == 'UserRole') { - return deserialize<_i18.UserRole>(data['data']); + return deserialize<_i21.UserRole>(data['data']); } if (dataClassName == 'AlertDispatchUnavailableException') { - return deserialize<_i19.AlertDispatchUnavailableException>(data['data']); + return deserialize<_i22.AlertDispatchUnavailableException>(data['data']); } if (dataClassName == 'AlertPermissionException') { - return deserialize<_i20.AlertPermissionException>(data['data']); + return deserialize<_i23.AlertPermissionException>(data['data']); } if (dataClassName == 'AlertValidationException') { - return deserialize<_i21.AlertValidationException>(data['data']); + return deserialize<_i24.AlertValidationException>(data['data']); } if (dataClassName == 'EndpointDisabledException') { - return deserialize<_i22.EndpointDisabledException>(data['data']); + return deserialize<_i25.EndpointDisabledException>(data['data']); } if (dataClassName == 'MicroArea') { - return deserialize<_i23.MicroArea>(data['data']); + return deserialize<_i26.MicroArea>(data['data']); } if (dataClassName == 'Patient') { - return deserialize<_i24.Patient>(data['data']); + return deserialize<_i27.Patient>(data['data']); } if (dataClassName == 'TriageAnswer') { - return deserialize<_i25.TriageAnswer>(data['data']); + return deserialize<_i28.TriageAnswer>(data['data']); } if (dataClassName == 'TriageSession') { - return deserialize<_i26.TriageSession>(data['data']); + return deserialize<_i29.TriageSession>(data['data']); } if (dataClassName == 'Ubs') { - return deserialize<_i27.Ubs>(data['data']); + return deserialize<_i30.Ubs>(data['data']); } if (dataClassName == 'User') { - return deserialize<_i28.User>(data['data']); + return deserialize<_i31.User>(data['data']); } if (dataClassName == 'Visit') { - return deserialize<_i29.Visit>(data['data']); + return deserialize<_i32.Visit>(data['data']); } if (dataClassName.startsWith('serverpod.')) { data['className'] = dataClassName.substring(10); @@ -1706,22 +1803,22 @@ class Protocol extends _i1.SerializationManagerServer { return _i6.AlertIdempotencyKey.t; case _i7.AlertOutboxEntry: return _i7.AlertOutboxEntry.t; - case _i13.AuditLog: - return _i13.AuditLog.t; - case _i14.ConsentLog: - return _i14.ConsentLog.t; - case _i23.MicroArea: - return _i23.MicroArea.t; - case _i24.Patient: - return _i24.Patient.t; - case _i26.TriageSession: - return _i26.TriageSession.t; - case _i27.Ubs: - return _i27.Ubs.t; - case _i28.User: - return _i28.User.t; - case _i29.Visit: - return _i29.Visit.t; + case _i16.AuditLog: + return _i16.AuditLog.t; + case _i17.ConsentLog: + return _i17.ConsentLog.t; + case _i26.MicroArea: + return _i26.MicroArea.t; + case _i27.Patient: + return _i27.Patient.t; + case _i29.TriageSession: + return _i29.TriageSession.t; + case _i30.Ubs: + return _i30.Ubs.t; + case _i31.User: + return _i31.User.t; + case _i32.Visit: + return _i32.Visit.t; } return null; } diff --git a/backend/sinalacs_server/lib/src/generated/protocol.yaml b/backend/sinalacs_server/lib/src/generated/protocol.yaml index fb23816..436cc59 100644 --- a/backend/sinalacs_server/lib/src/generated/protocol.yaml +++ b/backend/sinalacs_server/lib/src/generated/protocol.yaml @@ -5,5 +5,9 @@ auth: - developmentLogin: health: - check: +patients: + - listMicroArea: triage: - evaluate: +visits: + - sync: diff --git a/backend/sinalacs_server/lib/src/infrastructure/database/orm_audit_chain_reader.dart b/backend/sinalacs_server/lib/src/infrastructure/database/orm_audit_chain_reader.dart new file mode 100644 index 0000000..28bf7fb --- /dev/null +++ b/backend/sinalacs_server/lib/src/infrastructure/database/orm_audit_chain_reader.dart @@ -0,0 +1,37 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/audit/audit_chain.dart'; +import 'package:sinalacs_server/src/application/audit/audit_chain_verifier.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; + +/// Implementação de [AuditChainReader] sobre o ORM do Serverpod. +class OrmAuditChainReader implements AuditChainReader { + OrmAuditChainReader({required Session Function() session}) : _session = session; + + final Session Function() _session; + + @override + Future> readInOrder() async { + final rows = await AuditLog.db.find( + _session(), + orderBy: (t) => t.sequence, + ); + + return [ + for (final row in rows) + AuditChainEntry( + fields: AuditChainFields( + sequence: row.sequence, + previousHash: row.previousHash, + userId: row.userId.uuid, + actionType: row.actionType, + resourceType: row.resourceType, + resourceId: row.resourceId?.uuid, + timestamp: row.timestamp, + ipHash: row.ipHash, + result: row.result, + ), + entryHash: row.entryHash, + ), + ]; + } +} diff --git a/backend/sinalacs_server/lib/src/infrastructure/database/orm_audit_trail.dart b/backend/sinalacs_server/lib/src/infrastructure/database/orm_audit_trail.dart new file mode 100644 index 0000000..4f46e6c --- /dev/null +++ b/backend/sinalacs_server/lib/src/infrastructure/database/orm_audit_trail.dart @@ -0,0 +1,93 @@ +import 'package:crypto/crypto.dart'; +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/audit/audit_chain.dart'; +import 'package:sinalacs_server/src/application/audit/audit_trail.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; + +/// Implementação de [AuditTrail] sobre o ORM do Serverpod. +/// +/// `ipHash` nunca é o IP em claro — a §51 de spec/lgpd_design.md pede +/// "IP (anonimizado)", e o próprio nome do campo promete isso. Usa +/// `session.request.remoteInfo`, que o Serverpod já resolve corretamente atrás +/// de proxy (prefere `Forwarded`/`X-Forwarded-For` antes do endereço da +/// conexão) — decisivo aqui porque a stack tem Traefik na frente, e o endereço +/// da conexão seria sempre o do proxy, não o do ACS. +/// +/// Cada gravação também estende a cadeia de hash de `audit_logs` (LGPD-RT03): +/// lê a última linha e insere a próxima dentro da MESMA transação, sob um +/// advisory lock do Postgres — sem isso, duas gravações concorrentes leriam a +/// mesma última linha e encadeariam nela ao mesmo tempo, bifurcando a cadeia. +/// `pg_advisory_xact_lock` solta sozinho no fim da transação (commit ou +/// rollback), então não existe caminho que deixe o lock preso. +class OrmAuditTrail extends AuditTrail { + OrmAuditTrail({ + required Session Function() session, + required String chainSecret, + }) : _session = session, + _chain = AuditChain(secret: chainSecret); + + final Session Function() _session; + final AuditChain _chain; + + /// Chave arbitrária e fixa do advisory lock que serializa o apêndice à + /// cadeia. Só precisa ser estável entre chamadas — não é derivada de nada. + static const _chainLockKey = 725100823; + + @override + Future record(AuditEvent event) async { + final session = _session(); + // Nulo em sessões sem requisição HTTP (ex.: tarefas internas). Nunca cai + // para string vazia, que se confundiria com "IP resolvido, mas vazio" — + // um marcador explícito deixa a ausência de request auditável também. + final remoteInfo = session.request?.remoteInfo ?? 'sem-requisicao-http'; + final ipHash = sha256.convert(remoteInfo.codeUnits).toString(); + final resourceId = event.resourceId; + final timestamp = DateTime.now().toUtc(); + + await session.db.transaction((transaction) async { + await session.db.unsafeExecute( + 'SELECT pg_advisory_xact_lock(@key);', + parameters: QueryParameters.named({'key': _chainLockKey}), + transaction: transaction, + ); + + final last = await AuditLog.db.findFirstRow( + session, + orderBy: (t) => t.sequence, + orderDescending: true, + transaction: transaction, + ); + + final sequence = (last?.sequence ?? 0) + 1; + final previousHash = last?.entryHash ?? AuditChain.genesisHash; + final entryHash = _chain.computeEntryHash(AuditChainFields( + sequence: sequence, + previousHash: previousHash, + userId: event.userId, + actionType: event.actionType, + resourceType: event.resourceType, + resourceId: resourceId, + timestamp: timestamp, + ipHash: ipHash, + result: event.result, + )); + + await AuditLog.db.insertRow( + session, + AuditLog( + userId: UuidValue.fromString(event.userId), + actionType: event.actionType, + resourceType: event.resourceType, + resourceId: resourceId == null ? null : UuidValue.fromString(resourceId), + timestamp: timestamp, + ipHash: ipHash, + result: event.result, + sequence: sequence, + previousHash: previousHash, + entryHash: entryHash, + ), + transaction: transaction, + ); + }); + } +} diff --git a/backend/sinalacs_server/lib/src/infrastructure/database/orm_patient_directory_store.dart b/backend/sinalacs_server/lib/src/infrastructure/database/orm_patient_directory_store.dart new file mode 100644 index 0000000..fca2e90 --- /dev/null +++ b/backend/sinalacs_server/lib/src/infrastructure/database/orm_patient_directory_store.dart @@ -0,0 +1,47 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/patients/patient_directory_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; + +/// Implementação de [PatientDirectoryStore] sobre o ORM do Serverpod. +/// +/// `Patient` não guarda `microAreaId` — ela vive em `users`, e `Patient.id` É +/// o UUID do usuário (mesma decisão de chave documentada em +/// `models/patient.spy.yaml`). Por isso a consulta é em duas etapas: primeiro +/// os `users` da microárea com papel `patient`, depois os `patients` cujo +/// `id` está nesse conjunto — não há relação declarada entre as duas tabelas +/// para um join automático do ORM. +class OrmPatientDirectoryStore implements PatientDirectoryStore { + OrmPatientDirectoryStore({required Session Function() session}) : _session = session; + + final Session Function() _session; + + @override + Future> listByMicroArea(String microAreaId) async { + final areaId = UuidValue.fromString(microAreaId); + + final users = await User.db.find( + _session(), + where: (t) => t.microAreaId.equals(areaId) & t.role.equals(UserRole.patient), + ); + if (users.isEmpty) return const []; + + final userIds = {for (final user in users) user.id!}.cast(); + final patients = await Patient.db.find( + _session(), + where: (t) => t.id.inSet(userIds), + ); + + final namesById = {for (final user in users) user.id!: user.name}; + + return [ + for (final patient in patients) + PatientDirectoryEntry( + patientId: patient.id!.uuid, + // O nome vive em `users`; `patients` não o duplica. + name: namesById[patient.id] ?? '', + isChronic: patient.isChronic, + chronicConditions: patient.chronicConditions, + ), + ]; + } +} diff --git a/backend/sinalacs_server/lib/src/infrastructure/database/orm_visit_store.dart b/backend/sinalacs_server/lib/src/infrastructure/database/orm_visit_store.dart new file mode 100644 index 0000000..a24d4b0 --- /dev/null +++ b/backend/sinalacs_server/lib/src/infrastructure/database/orm_visit_store.dart @@ -0,0 +1,50 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/visits/visit_sync_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; + +/// Implementação de [VisitStore] sobre o ORM do Serverpod. +/// +/// Segue o mesmo arranjo de [OrmAlertStore]: a sessão é obtida por chamada, e +/// não guardada no construtor, porque o Serverpod amarra o ciclo de vida da +/// conexão à `Session` da requisição. +class OrmVisitStore implements VisitStore { + OrmVisitStore({ + required Session Function() session, + Transaction? transaction, + }) : _session = session, + _transaction = transaction; + + final Session Function() _session; + final Transaction? _transaction; + + @override + Future findByLocalId(String localId) async { + // `visits.localId` tem índice único (visits_local_id_key), então esta busca + // é o ponto de deduplicação do reenvio de um lote. + return Visit.db.findFirstRow( + _session(), + where: (visit) => visit.localId.equals(UuidValue.fromString(localId)), + transaction: _transaction, + ); + } + + @override + Future insert(Visit visit) => + Visit.db.insertRow(_session(), visit, transaction: _transaction); + + @override + Future update(Visit visit) => + Visit.db.updateRow(_session(), visit, transaction: _transaction); + + @override + Future microAreaOfPatient(UuidValue patientId) async { + // `Patient.id` É o UUID do usuário (mesma decisão de `patient.spy.yaml`), + // então a microárea vem de `users`, não de `patients`. + final user = await User.db.findFirstRow( + _session(), + where: (t) => t.id.equals(patientId), + transaction: _transaction, + ); + return user?.microAreaId; + } +} diff --git a/backend/sinalacs_server/lib/src/infrastructure/database/seeds/development.sql b/backend/sinalacs_server/lib/src/infrastructure/database/seeds/development.sql index 9528b5a..6db4f0e 100644 --- a/backend/sinalacs_server/lib/src/infrastructure/database/seeds/development.sql +++ b/backend/sinalacs_server/lib/src/infrastructure/database/seeds/development.sql @@ -19,12 +19,28 @@ ON CONFLICT ("id") DO NOTHING; INSERT INTO "users" ("id", "cpfHash", "name", "birthDate", "role", "microAreaId", "createdAt", "updatedAt") VALUES ('00000000-0000-4000-8000-000000000001', 'development-patient', 'Paciente de desenvolvimento', '1990-01-01', 'patient', '00000000-0000-4000-8000-000000000003', NOW(), NOW()), - ('00000000-0000-4000-8000-000000000002', 'development-acs', 'ACS de desenvolvimento', '1980-01-01', 'acs', '00000000-0000-4000-8000-000000000003', NOW(), NOW()) + ('00000000-0000-4000-8000-000000000002', 'development-acs', 'ACS de desenvolvimento', '1980-01-01', 'acs', '00000000-0000-4000-8000-000000000003', NOW(), NOW()), + -- Pacientes sintéticos adicionais, só para o seletor da visita de rotina ter + -- de onde escolher. Nomes obviamente fictícios (regra do repositório: nunca + -- dado real em seed/teste/log). + ('00000000-0000-4000-8000-000000000005', 'development-patient-05', 'Fulano de Tal', '1975-03-10', 'patient', '00000000-0000-4000-8000-000000000003', NOW(), NOW()), + ('00000000-0000-4000-8000-000000000006', 'development-patient-06', 'Ciclana da Silva', '1988-07-22', 'patient', '00000000-0000-4000-8000-000000000003', NOW(), NOW()), + ('00000000-0000-4000-8000-000000000007', 'development-patient-07', 'Beltrano de Souza', '1962-11-30', 'patient', '00000000-0000-4000-8000-000000000003', NOW(), NOW()), + ('00000000-0000-4000-8000-000000000008', 'development-patient-08', 'Sicrana Pereira', '1999-05-14', 'patient', '00000000-0000-4000-8000-000000000003', NOW(), NOW()), + -- Fora da microárea do seed: prova de que o diretório e o sync recusam + -- território alheio, sem precisar de outra stack de teste. + ('00000000-0000-4000-8000-000000000009', 'development-patient-09', 'Paciente de Outra Área', '1970-01-01', 'patient', '00000000-0000-4000-8000-000000000099', NOW(), NOW()) ON CONFLICT ("id") DO NOTHING; -- id = UUID do usuário paciente. INSERT INTO "patients" ("id", "emergencyContact", "isChronic", "chronicConditions") -VALUES ('00000000-0000-4000-8000-000000000001', 'Contato de desenvolvimento', false, '[]') +VALUES + ('00000000-0000-4000-8000-000000000001', 'Contato de desenvolvimento', false, '[]'), + ('00000000-0000-4000-8000-000000000005', 'Contato de desenvolvimento', true, '["hipertensão"]'), + ('00000000-0000-4000-8000-000000000006', 'Contato de desenvolvimento', false, '[]'), + ('00000000-0000-4000-8000-000000000007', 'Contato de desenvolvimento', true, '["diabetes", "hipertensão"]'), + ('00000000-0000-4000-8000-000000000008', 'Contato de desenvolvimento', false, '[]'), + ('00000000-0000-4000-8000-000000000009', 'Contato de desenvolvimento', false, '[]') ON CONFLICT ("id") DO NOTHING; -- id = UUID do usuário ACS. diff --git a/backend/sinalacs_server/lib/src/models/api/micro_area_patient.spy.yaml b/backend/sinalacs_server/lib/src/models/api/micro_area_patient.spy.yaml new file mode 100644 index 0000000..74f34f6 --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/api/micro_area_patient.spy.yaml @@ -0,0 +1,13 @@ +### Um paciente da microárea do ACS, para escolher em quem registrar uma visita +### de rotina. +### +### Minimização (LGPD §5.6, spec/lgpd_design.md:364): só o que uma visita de +### rotina precisa para escolher o paciente certo — nome e condições crônicas. +### `emergencyContact` fica de fora de propósito: não ajuda a escolher quem +### visitar. Não existe campo de endereço porque `Patient` não tem essa coluna. +class: MicroAreaPatient +fields: + patientId: String + name: String + isChronic: bool + chronicConditions: List diff --git a/backend/sinalacs_server/lib/src/models/api/visit_sync_entry.spy.yaml b/backend/sinalacs_server/lib/src/models/api/visit_sync_entry.spy.yaml new file mode 100644 index 0000000..afffca1 --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/api/visit_sync_entry.spy.yaml @@ -0,0 +1,18 @@ +### Uma visita registrada offline, enviada pelo app do ACS para sincronização. +### +### `localId` é gerado no dispositivo e tem índice único em `visits`: é o que +### permite reenviar o mesmo lote depois de uma falha de rede sem duplicar a +### visita. `version` é a versão que o dispositivo conhece — divergir da versão +### do servidor significa que alguém alterou a visita no meio, e o resultado é +### conflito, não sobrescrita. +class: VisitSyncEntry +fields: + localId: String + patientId: String + scheduledAt: DateTime + completedAt: DateTime? + status: String + riskLevelBefore: RiskLevel + riskLevelAfter: RiskLevel? + notes: Map + version: int diff --git a/backend/sinalacs_server/lib/src/models/api/visit_sync_result.spy.yaml b/backend/sinalacs_server/lib/src/models/api/visit_sync_result.spy.yaml new file mode 100644 index 0000000..5a9ebac --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/api/visit_sync_result.spy.yaml @@ -0,0 +1,14 @@ +### Resultado da sincronização de UMA visita. +### +### O app casa pelo `localId`, não pela posição na lista: um resultado ausente +### deixa a visita pendente para a próxima tentativa, em vez de dá-la por +### sincronizada. +class: VisitSyncResult +fields: + localId: String + syncStatus: SyncStatus + ### Versão gravada no servidor. Em conflito, é a versão que o dispositivo + ### precisa reconciliar antes de reenviar. + serverVersion: int? + ### Preenchido apenas quando syncStatus é error. + message: String? diff --git a/backend/sinalacs_server/lib/src/models/audit_log.spy.yaml b/backend/sinalacs_server/lib/src/models/audit_log.spy.yaml index 1d2894d..8518187 100644 --- a/backend/sinalacs_server/lib/src/models/audit_log.spy.yaml +++ b/backend/sinalacs_server/lib/src/models/audit_log.spy.yaml @@ -1,4 +1,14 @@ ### Trilha de auditoria de acesso a dados sensíveis. Append-only. +### +### `sequence`/`previousHash`/`entryHash` formam uma cadeia de hash (LGPD-RT03): +### cada linha encadeia à anterior via `previousHash == entryHash` da linha de +### `sequence - 1`, e `entryHash` é um HMAC-SHA256 sobre o conteúdo da própria +### linha (ver `application/audit/audit_chain.dart`). Isso torna qualquer +### edição, remoção ou reordenação de linha detectável — inclusive por quem tem +### acesso de escrita direto ao Postgres, que é o adversário que a §458 de +### spec/lgpd_design.md descreve. O índice único em `sequence` é o segundo +### cinto: uma bifurcação da cadeia por concorrência estoura na hora em vez de +### corromper em silêncio. class: AuditLog table: audit_logs fields: @@ -10,3 +20,16 @@ fields: timestamp: DateTime ipHash: String result: String + ### Posição na cadeia, começando em 1. Contígua por construção — um buraco + ### aqui é uma linha apagada. + sequence: int + ### `entryHash` da linha anterior, ou `AuditChain.genesisHash` (64 zeros) na + ### primeira linha. Nunca nulo: gênese e "escrito antes da cadeia existir" + ### não podem ter a mesma representação. + previousHash: String + ### HMAC-SHA256(AUDIT_CHAIN_SECRET, conteúdo da linha). Ver AuditChain.compute. + entryHash: String +indexes: + audit_logs_sequence_idx: + fields: sequence + unique: true diff --git a/backend/sinalacs_server/lib/src/models/enums/sync_status.spy.yaml b/backend/sinalacs_server/lib/src/models/enums/sync_status.spy.yaml index 4a628fc..9aea5af 100644 --- a/backend/sinalacs_server/lib/src/models/enums/sync_status.spy.yaml +++ b/backend/sinalacs_server/lib/src/models/enums/sync_status.spy.yaml @@ -1,4 +1,10 @@ ### Estado de sincronização offline-first, espelha a SyncFsm. +### +### `rejected` é TERMINAL: ao contrário de `error`, que é retentável (o +### dispositivo tenta de novo mais tarde), uma visita `rejected` nunca vai dar +### certo numa próxima tentativa — o motivo não muda com o tempo (ex.: paciente +### fora da microárea do ACS, identificador malformado). `SyncFsm.rejected` não +### tem transição de saída; `networkUp`/`syncStart` não o alcançam. enum: SyncStatus serialized: byName values: @@ -6,3 +12,4 @@ values: - synced - conflict - error + - rejected diff --git a/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart b/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart index cb65330..42548e8 100644 --- a/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart +++ b/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart @@ -2,10 +2,16 @@ import 'package:meta/meta.dart'; import 'package:serverpod/serverpod.dart'; import 'package:sinalacs_server/src/application/alerts/alert_outbox_dispatcher.dart'; import 'package:sinalacs_server/src/application/alerts/red_alert_service.dart'; +import 'package:sinalacs_server/src/application/audit/audit_trail.dart'; import 'package:sinalacs_server/src/application/auth/development_auth_service.dart'; +import 'package:sinalacs_server/src/application/patients/patient_directory_service.dart'; +import 'package:sinalacs_server/src/application/visits/visit_sync_service.dart'; import 'package:sinalacs_server/src/config/app_config.dart'; import 'package:sinalacs_server/src/infrastructure/database/orm_alert_outbox.dart'; import 'package:sinalacs_server/src/infrastructure/database/orm_alert_store.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_audit_trail.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_patient_directory_store.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_visit_store.dart'; import 'package:sinalacs_server/src/infrastructure/mqtt/mqtt_alert_dispatcher.dart'; /// Estado de processo compartilhado pelos endpoints. @@ -73,6 +79,32 @@ class AlertRuntime { outbox: OrmAlertOutbox(session: () => session, transaction: transaction), ); + /// Constrói o serviço de sincronização de visitas para uma requisição. + /// + /// Mesmo arranjo de [serviceFor]: o store é amarrado à sessão da chamada e, se + /// houver [transaction], o lote inteiro participa dela. + VisitSyncService visitSyncServiceFor( + Session session, { + Transaction? transaction, + }) => + VisitSyncService( + store: OrmVisitStore(session: () => session, transaction: transaction), + audit: auditTrailFor(session), + ); + + /// Constrói o diretório de pacientes da microárea para uma requisição. + PatientDirectoryService patientDirectoryServiceFor(Session session) => + PatientDirectoryService( + store: OrmPatientDirectoryStore(session: () => session), + audit: auditTrailFor(session), + ); + + /// Trilha de auditoria amarrada à sessão da chamada. + AuditTrail auditTrailFor(Session session) => OrmAuditTrail( + session: () => session, + chainSecret: config.auditChainSecret, + ); + /// Drenador do outbox. /// /// Construído sem transação: a publicação acontece **depois** do commit, e diff --git a/backend/sinalacs_server/migrations/20260914154730381/definition.json b/backend/sinalacs_server/migrations/20260914154730381/definition.json new file mode 100644 index 0000000..54cb3ba --- /dev/null +++ b/backend/sinalacs_server/migrations/20260914154730381/definition.json @@ -0,0 +1,2750 @@ +{ + "__className__": "serverpod.DatabaseDefinition", + "moduleName": "sinalacs", + "tables": [ + { + "__className__": "serverpod.TableDefinition", + "name": "acs", + "dartName": "Acs", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "enrollmentId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ubsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "active", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lastSyncAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_ubs_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "ubsId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "alert_deliveries", + "dartName": "AlertDeliveryRecord", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "alertId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "acsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "acknowledgedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alert_deliveries_fk_0", + "columns": [ + "alertId" + ], + "referenceTable": "alerts", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alert_deliveries_fk_1", + "columns": [ + "acsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_deliveries_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_deliveries_alert_acs_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "alertId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "acsId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "alert_idempotency_keys", + "dartName": "AlertIdempotencyKey", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "key", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "alertId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "locationHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alert_idempotency_keys_fk_0", + "columns": [ + "alertId" + ], + "referenceTable": "alerts", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_idempotency_keys_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_idempotency_keys_key_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "key" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "alert_outbox", + "dartName": "AlertOutboxEntry", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "alertId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "topic", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "payload", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "publishedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "nextAttemptAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lastError", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alert_outbox_fk_0", + "columns": [ + "alertId" + ], + "referenceTable": "alerts", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_outbox_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_outbox_pending_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "publishedAt" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "nextAttemptAt" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "alerts", + "dartName": "Alert", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "acsId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "triggeredAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "receivedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "respondedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "acknowledgedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "riskLevel", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:RiskLevel" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "locationHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:AlertStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "mqttTopic", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "retryCount", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "version", + "columnType": 6, + "isNullable": false, + "dartType": "int" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alerts_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alerts_fk_1", + "columns": [ + "acsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alerts_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alerts_micro_area_status_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "microAreaId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "status" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "triggeredAt" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "audit_logs", + "dartName": "AuditLog", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "actionType", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resourceType", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resourceId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "timestamp", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ipHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "result", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "sequence", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "previousHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "entryHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "audit_logs_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_logs_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_logs_sequence_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "sequence" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "consent_logs", + "dartName": "ConsentLog", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "purpose", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "action", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "version", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "timestamp", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ipHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userAgent", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "signature", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "consent_logs_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "consent_logs_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "micro_areas", + "dartName": "MicroArea", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "name", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ubsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "geoJsonBoundary", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "micro_areas_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "micro_areas_ubs_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "ubsId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "patients", + "dartName": "Patient", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "emergencyContact", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "isChronic", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditions", + "columnType": 8, + "isNullable": false, + "dartType": "List" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lastLocationHash", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lastTriageAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "patients_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "triage_sessions", + "dartName": "TriageSession", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answers", + "columnType": 8, + "isNullable": false, + "dartType": "List" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resultRisk", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:RiskLevel" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resultDisplay", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "triage_sessions_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "triage_sessions_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "ubs", + "dartName": "Ubs", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "name", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "address", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "city", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "state", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "ubs_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "users", + "dartName": "User", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "cpfHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "name", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "birthDate", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "role", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:UserRole" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "users_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "users_cpf_hash_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "users_micro_area_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "microAreaId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "visits", + "dartName": "Visit", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "acsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "scheduledAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "startedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "completedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "riskLevelBefore", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:RiskLevel" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "riskLevelAfter", + "columnType": 0, + "isNullable": true, + "dartType": "protocol:RiskLevel?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notes", + "columnType": 8, + "isNullable": false, + "dartType": "Map" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "localId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "version", + "columnType": 6, + "isNullable": false, + "dartType": "int" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "visits_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "visits_fk_1", + "columns": [ + "acsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "visits_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "visits_local_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "localId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_cloud_storage", + "dartName": "CloudStorageEntry", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_cloud_storage_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "storageId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "path", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "addedTime", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiration", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "byteData", + "columnType": 5, + "isNullable": false, + "dartType": "dart:typed_data:ByteData" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "verified", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_cloud_storage_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_cloud_storage_path_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "storageId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "path" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_cloud_storage_expiration", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "expiration" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_cloud_storage_direct_upload", + "dartName": "CloudStorageDirectUploadEntry", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_cloud_storage_direct_upload_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "storageId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "path", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiration", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "authKey", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_cloud_storage_direct_upload_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_cloud_storage_direct_upload_storage_path", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "storageId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "path" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_future_call", + "dartName": "FutureCallEntry", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_future_call_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "name", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "time", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "serializedObject", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "serverId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "identifier", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_future_call_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_future_call_time_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "time" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_future_call_serverId_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "serverId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_future_call_identifier_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "identifier" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_health_connection_info", + "dartName": "ServerHealthConnectionInfo", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_health_connection_info_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "serverId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "timestamp", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "active", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "closing", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "idle", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "granularity", + "columnType": 6, + "isNullable": false, + "dartType": "int" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_health_connection_info_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_health_connection_info_timestamp_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "timestamp" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "serverId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "granularity" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_health_metric", + "dartName": "ServerHealthMetric", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_health_metric_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "name", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "serverId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "timestamp", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "isHealthy", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "value", + "columnType": 3, + "isNullable": false, + "dartType": "double" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "granularity", + "columnType": 6, + "isNullable": false, + "dartType": "int" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_health_metric_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_health_metric_timestamp_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "timestamp" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "serverId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "name" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "granularity" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_log", + "dartName": "LogEntry", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_log_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "sessionLogId", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "messageId", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "reference", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "serverId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "time", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "logLevel", + "columnType": 6, + "isNullable": false, + "dartType": "protocol:LogLevel" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "message", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "error", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "stackTrace", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "order", + "columnType": 6, + "isNullable": false, + "dartType": "int" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "serverpod_log_fk_0", + "columns": [ + "sessionLogId" + ], + "referenceTable": "serverpod_session_log", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 4 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_log_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_log_sessionLogId_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "sessionLogId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_message_log", + "dartName": "MessageLogEntry", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_message_log_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "sessionLogId", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "serverId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "messageId", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "endpoint", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "messageName", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "duration", + "columnType": 3, + "isNullable": false, + "dartType": "double" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "error", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "stackTrace", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "slow", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "order", + "columnType": 6, + "isNullable": false, + "dartType": "int" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "serverpod_message_log_fk_0", + "columns": [ + "sessionLogId" + ], + "referenceTable": "serverpod_session_log", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 4 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_message_log_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_method", + "dartName": "MethodInfo", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_method_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "endpoint", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "method", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_method_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_method_endpoint_method_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "endpoint" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "method" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_migrations", + "dartName": "DatabaseMigrationVersion", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_migrations_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "module", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "version", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "timestamp", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_migrations_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_migrations_ids", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "module" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_query_log", + "dartName": "QueryLogEntry", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_query_log_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "serverId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "sessionLogId", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "messageId", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "query", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "duration", + "columnType": 3, + "isNullable": false, + "dartType": "double" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "numRows", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "error", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "stackTrace", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "slow", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "order", + "columnType": 6, + "isNullable": false, + "dartType": "int" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "serverpod_query_log_fk_0", + "columns": [ + "sessionLogId" + ], + "referenceTable": "serverpod_session_log", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 4 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_query_log_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_query_log_sessionLogId_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "sessionLogId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_readwrite_test", + "dartName": "ReadWriteTestEntry", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_readwrite_test_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "number", + "columnType": 6, + "isNullable": false, + "dartType": "int" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_readwrite_test_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_runtime_settings", + "dartName": "RuntimeSettings", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_runtime_settings_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "logSettings", + "columnType": 8, + "isNullable": false, + "dartType": "protocol:LogSettings" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "logSettingsOverrides", + "columnType": 8, + "isNullable": false, + "dartType": "List" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "logServiceCalls", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "logMalformedCalls", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_runtime_settings_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_session_log", + "dartName": "SessionLogEntry", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_session_log_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "serverId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "time", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "module", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "endpoint", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "method", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "duration", + "columnType": 3, + "isNullable": true, + "dartType": "double?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "numQueries", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "slow", + "columnType": 1, + "isNullable": true, + "dartType": "bool?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "error", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "stackTrace", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "authenticatedUserId", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "isOpen", + "columnType": 1, + "isNullable": true, + "dartType": "bool?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "touched", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_session_log_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_session_log_serverid_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "serverId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_session_log_time_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "time" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_session_log_touched_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "touched" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_session_log_isopen_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "isOpen" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + } + ], + "installedModules": [ + { + "__className__": "serverpod.DatabaseMigrationVersion", + "module": "sinalacs", + "version": "20260914154730381" + }, + { + "__className__": "serverpod.DatabaseMigrationVersion", + "module": "serverpod", + "version": "20260129180959368" + } + ], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20260914154730381/definition.sql b/backend/sinalacs_server/migrations/20260914154730381/definition.sql new file mode 100644 index 0000000..d300463 --- /dev/null +++ b/backend/sinalacs_server/migrations/20260914154730381/definition.sql @@ -0,0 +1,565 @@ +BEGIN; + +-- +-- Class Acs as table acs +-- +CREATE TABLE "acs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "enrollmentId" text NOT NULL, + "ubsId" uuid NOT NULL, + "active" boolean NOT NULL, + "lastSyncAt" timestamp without time zone +); + +-- Indexes +CREATE INDEX "acs_ubs_idx" ON "acs" USING btree ("ubsId"); + +-- +-- Class AlertDeliveryRecord as table alert_deliveries +-- +CREATE TABLE "alert_deliveries" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "alertId" uuid NOT NULL, + "acsId" uuid NOT NULL, + "acknowledgedAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "alert_deliveries_alert_acs_key" ON "alert_deliveries" USING btree ("alertId", "acsId"); + +-- +-- Class AlertIdempotencyKey as table alert_idempotency_keys +-- +CREATE TABLE "alert_idempotency_keys" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "key" text NOT NULL, + "alertId" uuid NOT NULL, + "locationHash" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "alert_idempotency_keys_key_key" ON "alert_idempotency_keys" USING btree ("key"); + +-- +-- Class AlertOutboxEntry as table alert_outbox +-- +CREATE TABLE "alert_outbox" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "alertId" uuid NOT NULL, + "topic" text NOT NULL, + "payload" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "publishedAt" timestamp without time zone, + "attempts" bigint NOT NULL, + "nextAttemptAt" timestamp without time zone NOT NULL, + "lastError" text +); + +-- Indexes +CREATE INDEX "alert_outbox_pending_idx" ON "alert_outbox" USING btree ("publishedAt", "nextAttemptAt"); + +-- +-- Class Alert as table alerts +-- +CREATE TABLE "alerts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "patientId" uuid NOT NULL, + "acsId" uuid, + "microAreaId" uuid, + "triggeredAt" timestamp without time zone NOT NULL, + "receivedAt" timestamp without time zone, + "respondedAt" timestamp without time zone, + "acknowledgedAt" timestamp without time zone, + "riskLevel" text NOT NULL, + "locationHash" text NOT NULL, + "status" text NOT NULL, + "mqttTopic" text NOT NULL, + "deviceId" text NOT NULL, + "retryCount" bigint NOT NULL, + "version" bigint NOT NULL +); + +-- Indexes +CREATE INDEX "alerts_micro_area_status_idx" ON "alerts" USING btree ("microAreaId", "status", "triggeredAt"); + +-- +-- Class AuditLog as table audit_logs +-- +CREATE TABLE "audit_logs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "actionType" text NOT NULL, + "resourceType" text NOT NULL, + "resourceId" uuid, + "timestamp" timestamp without time zone NOT NULL, + "ipHash" text NOT NULL, + "result" text NOT NULL, + "sequence" bigint NOT NULL, + "previousHash" text NOT NULL, + "entryHash" text NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "audit_logs_sequence_idx" ON "audit_logs" USING btree ("sequence"); + +-- +-- Class ConsentLog as table consent_logs +-- +CREATE TABLE "consent_logs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "purpose" text NOT NULL, + "action" text NOT NULL, + "version" text NOT NULL, + "timestamp" timestamp without time zone NOT NULL, + "ipHash" text NOT NULL, + "userAgent" text NOT NULL, + "signature" text NOT NULL +); + +-- +-- Class MicroArea as table micro_areas +-- +CREATE TABLE "micro_areas" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "name" text NOT NULL, + "ubsId" uuid NOT NULL, + "geoJsonBoundary" text NOT NULL +); + +-- Indexes +CREATE INDEX "micro_areas_ubs_idx" ON "micro_areas" USING btree ("ubsId"); + +-- +-- Class Patient as table patients +-- +CREATE TABLE "patients" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "emergencyContact" text NOT NULL, + "isChronic" boolean NOT NULL, + "chronicConditions" json NOT NULL, + "lastLocationHash" text, + "lastTriageAt" timestamp without time zone +); + +-- +-- Class TriageSession as table triage_sessions +-- +CREATE TABLE "triage_sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "patientId" uuid NOT NULL, + "answers" json NOT NULL, + "resultRisk" text NOT NULL, + "resultDisplay" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "deviceId" text NOT NULL +); + +-- +-- Class Ubs as table ubs +-- +CREATE TABLE "ubs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "name" text NOT NULL, + "address" text NOT NULL, + "city" text NOT NULL, + "state" text NOT NULL +); + +-- +-- Class User as table users +-- +CREATE TABLE "users" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "cpfHash" text NOT NULL, + "name" text NOT NULL, + "birthDate" timestamp without time zone NOT NULL, + "role" text NOT NULL, + "microAreaId" uuid, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE INDEX "users_cpf_hash_idx" ON "users" USING btree ("cpfHash"); +CREATE INDEX "users_micro_area_idx" ON "users" USING btree ("microAreaId"); + +-- +-- Class Visit as table visits +-- +CREATE TABLE "visits" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "patientId" uuid NOT NULL, + "acsId" uuid NOT NULL, + "scheduledAt" timestamp without time zone NOT NULL, + "startedAt" timestamp without time zone, + "completedAt" timestamp without time zone, + "status" text NOT NULL, + "riskLevelBefore" text NOT NULL, + "riskLevelAfter" text, + "notes" json NOT NULL, + "syncStatus" text NOT NULL, + "localId" uuid NOT NULL, + "syncAt" timestamp without time zone, + "version" bigint NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "visits_local_id_key" ON "visits" USING btree ("localId"); + +-- +-- Class CloudStorageEntry as table serverpod_cloud_storage +-- +CREATE TABLE "serverpod_cloud_storage" ( + "id" bigserial PRIMARY KEY, + "storageId" text NOT NULL, + "path" text NOT NULL, + "addedTime" timestamp without time zone NOT NULL, + "expiration" timestamp without time zone, + "byteData" bytea NOT NULL, + "verified" boolean NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "serverpod_cloud_storage_path_idx" ON "serverpod_cloud_storage" USING btree ("storageId", "path"); +CREATE INDEX "serverpod_cloud_storage_expiration" ON "serverpod_cloud_storage" USING btree ("expiration"); + +-- +-- Class CloudStorageDirectUploadEntry as table serverpod_cloud_storage_direct_upload +-- +CREATE TABLE "serverpod_cloud_storage_direct_upload" ( + "id" bigserial PRIMARY KEY, + "storageId" text NOT NULL, + "path" text NOT NULL, + "expiration" timestamp without time zone NOT NULL, + "authKey" text NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "serverpod_cloud_storage_direct_upload_storage_path" ON "serverpod_cloud_storage_direct_upload" USING btree ("storageId", "path"); + +-- +-- Class FutureCallEntry as table serverpod_future_call +-- +CREATE TABLE "serverpod_future_call" ( + "id" bigserial PRIMARY KEY, + "name" text NOT NULL, + "time" timestamp without time zone NOT NULL, + "serializedObject" text, + "serverId" text NOT NULL, + "identifier" text +); + +-- Indexes +CREATE INDEX "serverpod_future_call_time_idx" ON "serverpod_future_call" USING btree ("time"); +CREATE INDEX "serverpod_future_call_serverId_idx" ON "serverpod_future_call" USING btree ("serverId"); +CREATE INDEX "serverpod_future_call_identifier_idx" ON "serverpod_future_call" USING btree ("identifier"); + +-- +-- Class ServerHealthConnectionInfo as table serverpod_health_connection_info +-- +CREATE TABLE "serverpod_health_connection_info" ( + "id" bigserial PRIMARY KEY, + "serverId" text NOT NULL, + "timestamp" timestamp without time zone NOT NULL, + "active" bigint NOT NULL, + "closing" bigint NOT NULL, + "idle" bigint NOT NULL, + "granularity" bigint NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "serverpod_health_connection_info_timestamp_idx" ON "serverpod_health_connection_info" USING btree ("timestamp", "serverId", "granularity"); + +-- +-- Class ServerHealthMetric as table serverpod_health_metric +-- +CREATE TABLE "serverpod_health_metric" ( + "id" bigserial PRIMARY KEY, + "name" text NOT NULL, + "serverId" text NOT NULL, + "timestamp" timestamp without time zone NOT NULL, + "isHealthy" boolean NOT NULL, + "value" double precision NOT NULL, + "granularity" bigint NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "serverpod_health_metric_timestamp_idx" ON "serverpod_health_metric" USING btree ("timestamp", "serverId", "name", "granularity"); + +-- +-- Class LogEntry as table serverpod_log +-- +CREATE TABLE "serverpod_log" ( + "id" bigserial PRIMARY KEY, + "sessionLogId" bigint NOT NULL, + "messageId" bigint, + "reference" text, + "serverId" text NOT NULL, + "time" timestamp without time zone NOT NULL, + "logLevel" bigint NOT NULL, + "message" text NOT NULL, + "error" text, + "stackTrace" text, + "order" bigint NOT NULL +); + +-- Indexes +CREATE INDEX "serverpod_log_sessionLogId_idx" ON "serverpod_log" USING btree ("sessionLogId"); + +-- +-- Class MessageLogEntry as table serverpod_message_log +-- +CREATE TABLE "serverpod_message_log" ( + "id" bigserial PRIMARY KEY, + "sessionLogId" bigint NOT NULL, + "serverId" text NOT NULL, + "messageId" bigint NOT NULL, + "endpoint" text NOT NULL, + "messageName" text NOT NULL, + "duration" double precision NOT NULL, + "error" text, + "stackTrace" text, + "slow" boolean NOT NULL, + "order" bigint NOT NULL +); + +-- +-- Class MethodInfo as table serverpod_method +-- +CREATE TABLE "serverpod_method" ( + "id" bigserial PRIMARY KEY, + "endpoint" text NOT NULL, + "method" text NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "serverpod_method_endpoint_method_idx" ON "serverpod_method" USING btree ("endpoint", "method"); + +-- +-- Class DatabaseMigrationVersion as table serverpod_migrations +-- +CREATE TABLE "serverpod_migrations" ( + "id" bigserial PRIMARY KEY, + "module" text NOT NULL, + "version" text NOT NULL, + "timestamp" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "serverpod_migrations_ids" ON "serverpod_migrations" USING btree ("module"); + +-- +-- Class QueryLogEntry as table serverpod_query_log +-- +CREATE TABLE "serverpod_query_log" ( + "id" bigserial PRIMARY KEY, + "serverId" text NOT NULL, + "sessionLogId" bigint NOT NULL, + "messageId" bigint, + "query" text NOT NULL, + "duration" double precision NOT NULL, + "numRows" bigint, + "error" text, + "stackTrace" text, + "slow" boolean NOT NULL, + "order" bigint NOT NULL +); + +-- Indexes +CREATE INDEX "serverpod_query_log_sessionLogId_idx" ON "serverpod_query_log" USING btree ("sessionLogId"); + +-- +-- Class ReadWriteTestEntry as table serverpod_readwrite_test +-- +CREATE TABLE "serverpod_readwrite_test" ( + "id" bigserial PRIMARY KEY, + "number" bigint NOT NULL +); + +-- +-- Class RuntimeSettings as table serverpod_runtime_settings +-- +CREATE TABLE "serverpod_runtime_settings" ( + "id" bigserial PRIMARY KEY, + "logSettings" json NOT NULL, + "logSettingsOverrides" json NOT NULL, + "logServiceCalls" boolean NOT NULL, + "logMalformedCalls" boolean NOT NULL +); + +-- +-- Class SessionLogEntry as table serverpod_session_log +-- +CREATE TABLE "serverpod_session_log" ( + "id" bigserial PRIMARY KEY, + "serverId" text NOT NULL, + "time" timestamp without time zone NOT NULL, + "module" text, + "endpoint" text, + "method" text, + "duration" double precision, + "numQueries" bigint, + "slow" boolean, + "error" text, + "stackTrace" text, + "authenticatedUserId" bigint, + "userId" text, + "isOpen" boolean, + "touched" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE INDEX "serverpod_session_log_serverid_idx" ON "serverpod_session_log" USING btree ("serverId"); +CREATE INDEX "serverpod_session_log_time_idx" ON "serverpod_session_log" USING btree ("time"); +CREATE INDEX "serverpod_session_log_touched_idx" ON "serverpod_session_log" USING btree ("touched"); +CREATE INDEX "serverpod_session_log_isopen_idx" ON "serverpod_session_log" USING btree ("isOpen"); + +-- +-- Foreign relations for "alert_deliveries" table +-- +ALTER TABLE ONLY "alert_deliveries" + ADD CONSTRAINT "alert_deliveries_fk_0" + FOREIGN KEY("alertId") + REFERENCES "alerts"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "alert_deliveries" + ADD CONSTRAINT "alert_deliveries_fk_1" + FOREIGN KEY("acsId") + REFERENCES "acs"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "alert_idempotency_keys" table +-- +ALTER TABLE ONLY "alert_idempotency_keys" + ADD CONSTRAINT "alert_idempotency_keys_fk_0" + FOREIGN KEY("alertId") + REFERENCES "alerts"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "alert_outbox" table +-- +ALTER TABLE ONLY "alert_outbox" + ADD CONSTRAINT "alert_outbox_fk_0" + FOREIGN KEY("alertId") + REFERENCES "alerts"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "alerts" table +-- +ALTER TABLE ONLY "alerts" + ADD CONSTRAINT "alerts_fk_0" + FOREIGN KEY("patientId") + REFERENCES "patients"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "alerts" + ADD CONSTRAINT "alerts_fk_1" + FOREIGN KEY("acsId") + REFERENCES "acs"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "audit_logs" table +-- +ALTER TABLE ONLY "audit_logs" + ADD CONSTRAINT "audit_logs_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "consent_logs" table +-- +ALTER TABLE ONLY "consent_logs" + ADD CONSTRAINT "consent_logs_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "triage_sessions" table +-- +ALTER TABLE ONLY "triage_sessions" + ADD CONSTRAINT "triage_sessions_fk_0" + FOREIGN KEY("patientId") + REFERENCES "patients"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "visits" table +-- +ALTER TABLE ONLY "visits" + ADD CONSTRAINT "visits_fk_0" + FOREIGN KEY("patientId") + REFERENCES "patients"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "visits" + ADD CONSTRAINT "visits_fk_1" + FOREIGN KEY("acsId") + REFERENCES "acs"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "serverpod_log" table +-- +ALTER TABLE ONLY "serverpod_log" + ADD CONSTRAINT "serverpod_log_fk_0" + FOREIGN KEY("sessionLogId") + REFERENCES "serverpod_session_log"("id") + ON DELETE CASCADE + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "serverpod_message_log" table +-- +ALTER TABLE ONLY "serverpod_message_log" + ADD CONSTRAINT "serverpod_message_log_fk_0" + FOREIGN KEY("sessionLogId") + REFERENCES "serverpod_session_log"("id") + ON DELETE CASCADE + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "serverpod_query_log" table +-- +ALTER TABLE ONLY "serverpod_query_log" + ADD CONSTRAINT "serverpod_query_log_fk_0" + FOREIGN KEY("sessionLogId") + REFERENCES "serverpod_session_log"("id") + ON DELETE CASCADE + ON UPDATE NO ACTION; + + +-- +-- MIGRATION VERSION FOR sinalacs +-- +INSERT INTO "serverpod_migrations" ("module", "version", "timestamp") + VALUES ('sinalacs', '20260914154730381', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20260914154730381', "timestamp" = now(); + +-- +-- MIGRATION VERSION FOR serverpod +-- +INSERT INTO "serverpod_migrations" ("module", "version", "timestamp") + VALUES ('serverpod', '20260129180959368', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20260129180959368', "timestamp" = now(); + + +COMMIT; diff --git a/backend/sinalacs_server/migrations/20260914154730381/definition_project.json b/backend/sinalacs_server/migrations/20260914154730381/definition_project.json new file mode 100644 index 0000000..758a9a4 --- /dev/null +++ b/backend/sinalacs_server/migrations/20260914154730381/definition_project.json @@ -0,0 +1,1414 @@ +{ + "__className__": "serverpod.DatabaseDefinition", + "moduleName": "sinalacs", + "tables": [ + { + "__className__": "serverpod.TableDefinition", + "name": "acs", + "dartName": "Acs", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "enrollmentId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ubsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "active", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lastSyncAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_ubs_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "ubsId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "alert_deliveries", + "dartName": "AlertDeliveryRecord", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "alertId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "acsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "acknowledgedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alert_deliveries_fk_0", + "columns": [ + "alertId" + ], + "referenceTable": "alerts", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alert_deliveries_fk_1", + "columns": [ + "acsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_deliveries_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_deliveries_alert_acs_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "alertId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "acsId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "alert_idempotency_keys", + "dartName": "AlertIdempotencyKey", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "key", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "alertId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "locationHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alert_idempotency_keys_fk_0", + "columns": [ + "alertId" + ], + "referenceTable": "alerts", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_idempotency_keys_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_idempotency_keys_key_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "key" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "alert_outbox", + "dartName": "AlertOutboxEntry", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "alertId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "topic", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "payload", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "publishedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "nextAttemptAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lastError", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alert_outbox_fk_0", + "columns": [ + "alertId" + ], + "referenceTable": "alerts", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_outbox_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_outbox_pending_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "publishedAt" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "nextAttemptAt" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "alerts", + "dartName": "Alert", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "acsId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "triggeredAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "receivedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "respondedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "acknowledgedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "riskLevel", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:RiskLevel" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "locationHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:AlertStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "mqttTopic", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "retryCount", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "version", + "columnType": 6, + "isNullable": false, + "dartType": "int" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alerts_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alerts_fk_1", + "columns": [ + "acsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alerts_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alerts_micro_area_status_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "microAreaId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "status" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "triggeredAt" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "audit_logs", + "dartName": "AuditLog", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "actionType", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resourceType", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resourceId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "timestamp", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ipHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "result", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "sequence", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "previousHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "entryHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "audit_logs_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_logs_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_logs_sequence_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "sequence" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "consent_logs", + "dartName": "ConsentLog", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "purpose", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "action", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "version", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "timestamp", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ipHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userAgent", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "signature", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "consent_logs_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "consent_logs_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "micro_areas", + "dartName": "MicroArea", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "name", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ubsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "geoJsonBoundary", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "micro_areas_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "micro_areas_ubs_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "ubsId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "patients", + "dartName": "Patient", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "emergencyContact", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "isChronic", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditions", + "columnType": 8, + "isNullable": false, + "dartType": "List" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lastLocationHash", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lastTriageAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "patients_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "triage_sessions", + "dartName": "TriageSession", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answers", + "columnType": 8, + "isNullable": false, + "dartType": "List" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resultRisk", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:RiskLevel" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resultDisplay", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "triage_sessions_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "triage_sessions_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "ubs", + "dartName": "Ubs", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "name", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "address", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "city", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "state", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "ubs_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "users", + "dartName": "User", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "cpfHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "name", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "birthDate", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "role", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:UserRole" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "users_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "users_cpf_hash_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "users_micro_area_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "microAreaId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "visits", + "dartName": "Visit", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "acsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "scheduledAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "startedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "completedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "riskLevelBefore", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:RiskLevel" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "riskLevelAfter", + "columnType": 0, + "isNullable": true, + "dartType": "protocol:RiskLevel?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notes", + "columnType": 8, + "isNullable": false, + "dartType": "Map" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "localId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "version", + "columnType": 6, + "isNullable": false, + "dartType": "int" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "visits_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "visits_fk_1", + "columns": [ + "acsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "visits_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "visits_local_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "localId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + } + ], + "installedModules": [ + { + "__className__": "serverpod.DatabaseMigrationVersion", + "module": "serverpod", + "version": "20260129180959368" + } + ], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20260914154730381/migration.json b/backend/sinalacs_server/migrations/20260914154730381/migration.json new file mode 100644 index 0000000..153e5a7 --- /dev/null +++ b/backend/sinalacs_server/migrations/20260914154730381/migration.json @@ -0,0 +1,171 @@ +{ + "__className__": "serverpod.DatabaseMigration", + "actions": [ + { + "__className__": "serverpod.DatabaseMigrationAction", + "type": "deleteTable", + "deleteTable": "audit_logs" + }, + { + "__className__": "serverpod.DatabaseMigrationAction", + "type": "createTable", + "createTable": { + "__className__": "serverpod.TableDefinition", + "name": "audit_logs", + "dartName": "AuditLog", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "actionType", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resourceType", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resourceId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "timestamp", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ipHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "result", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "sequence", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "previousHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "entryHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "audit_logs_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_logs_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_logs_sequence_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "sequence" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + } + } + ], + "warnings": [ + { + "__className__": "serverpod.DatabaseMigrationWarning", + "type": "uniqueIndexCreated", + "message": "Unique index \"audit_logs_sequence_idx\" is added to table \"audit_logs\". If there are existing rows with duplicate values, this migration will fail.", + "table": "audit_logs", + "columns": [ + "sequence" + ], + "destrucive": false + }, + { + "__className__": "serverpod.DatabaseMigrationWarning", + "type": "tableDropped", + "message": "One or more columns are added to table \"audit_logs\" which cannot be added in a table migration. The complete table will be deleted and recreated.", + "table": "audit_logs", + "columns": [ + "sequence" + ], + "destrucive": true + } + ], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20260914154730381/migration.sql b/backend/sinalacs_server/migrations/20260914154730381/migration.sql new file mode 100644 index 0000000..401aa15 --- /dev/null +++ b/backend/sinalacs_server/migrations/20260914154730381/migration.sql @@ -0,0 +1,56 @@ +BEGIN; + +-- +-- ACTION DROP TABLE +-- +DROP TABLE "audit_logs" CASCADE; + +-- +-- ACTION CREATE TABLE +-- +CREATE TABLE "audit_logs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "actionType" text NOT NULL, + "resourceType" text NOT NULL, + "resourceId" uuid, + "timestamp" timestamp without time zone NOT NULL, + "ipHash" text NOT NULL, + "result" text NOT NULL, + "sequence" bigint NOT NULL, + "previousHash" text NOT NULL, + "entryHash" text NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "audit_logs_sequence_idx" ON "audit_logs" USING btree ("sequence"); + +-- +-- ACTION CREATE FOREIGN KEY +-- +ALTER TABLE ONLY "audit_logs" + ADD CONSTRAINT "audit_logs_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + + +-- +-- MIGRATION VERSION FOR sinalacs +-- +INSERT INTO "serverpod_migrations" ("module", "version", "timestamp") + VALUES ('sinalacs', '20260914154730381', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20260914154730381', "timestamp" = now(); + +-- +-- MIGRATION VERSION FOR serverpod +-- +INSERT INTO "serverpod_migrations" ("module", "version", "timestamp") + VALUES ('serverpod', '20260129180959368', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20260129180959368', "timestamp" = now(); + + +COMMIT; diff --git a/backend/sinalacs_server/migrations/migration_registry.txt b/backend/sinalacs_server/migrations/migration_registry.txt index 6b7c816..f3f08a4 100644 --- a/backend/sinalacs_server/migrations/migration_registry.txt +++ b/backend/sinalacs_server/migrations/migration_registry.txt @@ -6,3 +6,4 @@ 20260909011754405 20260909155542184 +20260914154730381 diff --git a/backend/sinalacs_server/test/integration/patient_directory_and_territory_test.dart b/backend/sinalacs_server/test/integration/patient_directory_and_territory_test.dart new file mode 100644 index 0000000..7a58eeb --- /dev/null +++ b/backend/sinalacs_server/test/integration/patient_directory_and_territory_test.dart @@ -0,0 +1,237 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/audit/audit_chain_verifier.dart'; +import 'package:sinalacs_server/src/config/app_config.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_audit_chain_reader.dart'; +import 'package:sinalacs_server/src/runtime/alert_runtime.dart'; +import 'package:test/test.dart'; + +import 'test_tools/serverpod_test_tools.dart'; + +/// Prova, contra Postgres real, o que `patient_directory_service_test.dart` e +/// `visit_sync_service_test.dart` só provam com fakes: o JOIN em duas etapas +/// de `OrmPatientDirectoryStore` (patients → users, sem relação declarada +/// entre as tabelas), o `inSet` da consulta, e a escrita de verdade em +/// `audit_logs` — tabela que, antes deste PR, não tinha nenhum escritor. +const _acsId = '00000000-0000-4000-8000-000000000002'; +const _microAreaId = '00000000-0000-4000-8000-000000000003'; +const _otherMicroAreaId = '00000000-0000-4000-8000-000000000099'; +const _ubsId = '00000000-0000-4000-8000-000000000004'; + +const _patientInAreaId = '00000000-0000-4000-8000-000000000005'; +const _patientOutsideAreaId = '00000000-0000-4000-8000-000000000009'; + +AppConfig _config() => AppConfig( + mqttBroker: 'localhost:1883', + jwtSecret: 'test-secret', + auditChainSecret: 'test-audit-chain-secret', + mqttUsername: null, + mqttPassword: null, + mqttUseTls: false, + mqttCaCertificatePath: null, + appEnv: 'development', + enableDevLogin: true, + ); + +Future _seed(Session session) async { + await Ubs.db.insertRow( + session, + Ubs( + id: UuidValue.fromString(_ubsId), + name: 'UBS Desenvolvimento', + address: 'Endereço local', + city: 'São Paulo', + state: 'SP', + ), + ); + await MicroArea.db.insert(session, [ + MicroArea( + id: UuidValue.fromString(_microAreaId), + name: 'Microárea 12', + ubsId: UuidValue.fromString(_ubsId), + geoJsonBoundary: '{}', + ), + MicroArea( + id: UuidValue.fromString(_otherMicroAreaId), + name: 'Microárea vizinha', + ubsId: UuidValue.fromString(_ubsId), + geoJsonBoundary: '{}', + ), + ]); + + final now = DateTime.now().toUtc(); + await User.db.insert(session, [ + User( + id: UuidValue.fromString(_acsId), + cpfHash: 'development-acs', + name: 'ACS de desenvolvimento', + birthDate: DateTime.utc(1980), + role: UserRole.acs, + microAreaId: UuidValue.fromString(_microAreaId), + createdAt: now, + updatedAt: now, + ), + User( + id: UuidValue.fromString(_patientInAreaId), + cpfHash: 'development-patient-05', + name: 'Fulano de Tal', + birthDate: DateTime.utc(1975, 3, 10), + role: UserRole.patient, + microAreaId: UuidValue.fromString(_microAreaId), + createdAt: now, + updatedAt: now, + ), + User( + id: UuidValue.fromString(_patientOutsideAreaId), + cpfHash: 'development-patient-09', + name: 'Paciente de Outra Área', + birthDate: DateTime.utc(1970), + role: UserRole.patient, + microAreaId: UuidValue.fromString(_otherMicroAreaId), + createdAt: now, + updatedAt: now, + ), + ]); + + await Acs.db.insertRow( + session, + Acs( + id: UuidValue.fromString(_acsId), + enrollmentId: 'ACS-001', + ubsId: UuidValue.fromString(_ubsId), + active: true, + ), + ); + await Patient.db.insert(session, [ + Patient( + id: UuidValue.fromString(_patientInAreaId), + emergencyContact: 'Contato de desenvolvimento', + isChronic: true, + chronicConditions: ['hipertensão'], + ), + Patient( + id: UuidValue.fromString(_patientOutsideAreaId), + emergencyContact: 'Contato de desenvolvimento', + isChronic: false, + chronicConditions: [], + ), + ]); +} + +void main() { + withServerpod('Dado o diretório de pacientes e a territorialização do sync', + (sessionBuilder, endpoints) { + setUp(() => AlertRuntime.instance.overrideConfig(_config())); + tearDown(() => AlertRuntime.instance.overrideConfig(null)); + + test('patients.listMicroArea devolve só o paciente da microárea do ACS, via JOIN real', + () async { + await _seed(sessionBuilder.build()); + + final login = await endpoints.auth.developmentLogin(sessionBuilder, role: 'acs'); + final result = await endpoints.patients.listMicroArea( + sessionBuilder, + accessToken: login.accessToken, + ); + + expect(result.map((p) => p.patientId), [_patientInAreaId]); + expect(result.single.name, 'Fulano de Tal'); + expect(result.single.isChronic, isTrue); + expect(result.single.chronicConditions, ['hipertensão']); + }); + + test('a leitura da lista grava uma linha real em audit_logs', () async { + final session = sessionBuilder.build(); + await _seed(session); + + final login = await endpoints.auth.developmentLogin(sessionBuilder, role: 'acs'); + await endpoints.patients.listMicroArea(sessionBuilder, accessToken: login.accessToken); + + final rows = await AuditLog.db.find( + session, + where: (t) => t.resourceType.equals('patient_directory'), + ); + expect(rows, hasLength(1)); + expect(rows.single.result, 'granted'); + expect(rows.single.userId, UuidValue.fromString(_acsId)); + // A trilha não guarda IP em claro — nem que seja o do harness de teste. + expect(rows.single.ipHash, isNotEmpty); + }); + + test('duas escritas reais na trilha ficam encadeadas e passam na verificação', + () async { + final session = sessionBuilder.build(); + await _seed(session); + + final login = await endpoints.auth.developmentLogin(sessionBuilder, role: 'acs'); + // Primeira escrita: leitura do diretório (granted). + await endpoints.patients.listMicroArea(sessionBuilder, accessToken: login.accessToken); + // Segunda escrita: recusa territorial no sync. + await endpoints.visits.sync( + sessionBuilder, + accessToken: login.accessToken, + visits: [ + VisitSyncEntry( + localId: '00000000-0000-4000-8000-0000000000b2', + patientId: _patientOutsideAreaId, + scheduledAt: DateTime.utc(2026, 9, 12, 9), + status: 'realizada', + riskLevelBefore: RiskLevel.green, + notes: const {}, + version: 0, + ), + ], + ); + + final rows = await AuditLog.db.find(session, orderBy: (t) => t.sequence); + expect(rows, hasLength(2)); + expect(rows[0].sequence, 1); + expect(rows[1].sequence, 2); + expect(rows[1].previousHash, rows[0].entryHash); + + final verifier = AuditChainVerifier( + reader: OrmAuditChainReader(session: () => session), + secret: 'test-audit-chain-secret', + ); + final result = await verifier.verify(); + + expect(result.ok, isTrue); + expect(result.checked, 2); + }); + + test('visits.sync recusa e audita visita para paciente de outra microárea, contra Postgres real', + () async { + final session = sessionBuilder.build(); + await _seed(session); + + final login = await endpoints.auth.developmentLogin(sessionBuilder, role: 'acs'); + final results = await endpoints.visits.sync( + sessionBuilder, + accessToken: login.accessToken, + visits: [ + VisitSyncEntry( + localId: '00000000-0000-4000-8000-0000000000b1', + patientId: _patientOutsideAreaId, + scheduledAt: DateTime.utc(2026, 9, 12, 9), + status: 'realizada', + riskLevelBefore: RiskLevel.green, + notes: const {}, + version: 0, + ), + ], + ); + + expect(results.single.syncStatus, SyncStatus.rejected); + + final rows = await Visit.db.find(session); + expect(rows, isEmpty, reason: 'nada deveria ser gravado para paciente fora do território'); + + final audited = await AuditLog.db.find( + session, + where: (t) => t.resourceType.equals('visit') & t.result.equals('denied_territory'), + ); + expect(audited, hasLength(1)); + expect(audited.single.resourceId, UuidValue.fromString(_patientOutsideAreaId)); + }); + }); +} diff --git a/backend/sinalacs_server/test/integration/red_alert_cycle_test.dart b/backend/sinalacs_server/test/integration/red_alert_cycle_test.dart index 6efbaab..13df521 100644 --- a/backend/sinalacs_server/test/integration/red_alert_cycle_test.dart +++ b/backend/sinalacs_server/test/integration/red_alert_cycle_test.dart @@ -36,9 +36,9 @@ const _microAreaId = '00000000-0000-4000-8000-000000000003'; const _ubsId = '00000000-0000-4000-8000-000000000004'; AppConfig _config({required bool enableDevLogin}) => AppConfig( - databaseUrl: 'postgresql://localhost/sinalacs_test', mqttBroker: 'localhost:1883', jwtSecret: 'test-secret', + auditChainSecret: 'test-audit-chain-secret', mqttUsername: null, mqttPassword: null, mqttUseTls: false, diff --git a/backend/sinalacs_server/test/integration/test_tools/serverpod_test_tools.dart b/backend/sinalacs_server/test/integration/test_tools/serverpod_test_tools.dart index 03ffd26..884b301 100644 --- a/backend/sinalacs_server/test/integration/test_tools/serverpod_test_tools.dart +++ b/backend/sinalacs_server/test/integration/test_tools/serverpod_test_tools.dart @@ -19,7 +19,13 @@ import 'package:sinalacs_server/src/generated/api/alert_ack_result.dart' as _i5; import 'package:sinalacs_server/src/generated/api/development_login_result.dart' as _i6; import 'package:sinalacs_server/src/generated/api/service_health.dart' as _i7; -import 'package:sinalacs_server/src/generated/api/triage_result.dart' as _i8; +import 'package:sinalacs_server/src/generated/api/micro_area_patient.dart' + as _i8; +import 'package:sinalacs_server/src/generated/api/triage_result.dart' as _i9; +import 'package:sinalacs_server/src/generated/api/visit_sync_result.dart' + as _i10; +import 'package:sinalacs_server/src/generated/api/visit_sync_entry.dart' + as _i11; import 'package:sinalacs_server/src/generated/protocol.dart'; import 'package:sinalacs_server/src/generated/endpoints.dart'; export 'package:serverpod_test/serverpod_test_public_exports.dart'; @@ -140,7 +146,11 @@ class TestEndpoints { late final _HealthEndpoint health; + late final _PatientsEndpoint patients; + late final _TriageEndpoint triage; + + late final _VisitsEndpoint visits; } class _InternalTestEndpoints extends TestEndpoints @@ -162,10 +172,18 @@ class _InternalTestEndpoints extends TestEndpoints endpoints, serializationManager, ); + patients = _PatientsEndpoint( + endpoints, + serializationManager, + ); triage = _TriageEndpoint( endpoints, serializationManager, ); + visits = _VisitsEndpoint( + endpoints, + serializationManager, + ); } } @@ -335,6 +353,48 @@ class _HealthEndpoint { } } +class _PatientsEndpoint { + _PatientsEndpoint( + this._endpointDispatch, + this._serializationManager, + ); + + final _i2.EndpointDispatch _endpointDispatch; + + final _i2.SerializationManager _serializationManager; + + _i3.Future> listMicroArea( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'patients', + method: 'listMicroArea', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'patients', + methodName: 'listMicroArea', + parameters: _i1.testObjectToJson({'accessToken': accessToken}), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } +} + class _TriageEndpoint { _TriageEndpoint( this._endpointDispatch, @@ -345,7 +405,7 @@ class _TriageEndpoint { final _i2.SerializationManager _serializationManager; - _i3.Future<_i8.TriageResult> evaluate( + _i3.Future<_i9.TriageResult> evaluate( _i1.TestSessionBuilder sessionBuilder, { required bool chestPain, required bool difficultyBreathing, @@ -380,7 +440,53 @@ class _TriageEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i8.TriageResult>); + as _i3.Future<_i9.TriageResult>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } +} + +class _VisitsEndpoint { + _VisitsEndpoint( + this._endpointDispatch, + this._serializationManager, + ); + + final _i2.EndpointDispatch _endpointDispatch; + + final _i2.SerializationManager _serializationManager; + + _i3.Future> sync( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + required List<_i11.VisitSyncEntry> visits, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'visits', + method: 'sync', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'visits', + methodName: 'sync', + parameters: _i1.testObjectToJson({ + 'accessToken': accessToken, + 'visits': visits, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future>); return _localReturnValue; } finally { await _localUniqueSession.close(); diff --git a/backend/sinalacs_server/test/unit/app_config_test.dart b/backend/sinalacs_server/test/unit/app_config_test.dart new file mode 100644 index 0000000..b851ef6 --- /dev/null +++ b/backend/sinalacs_server/test/unit/app_config_test.dart @@ -0,0 +1,193 @@ +import 'package:sinalacs_server/src/config/app_config.dart'; +import 'package:test/test.dart'; + +/// Regras de aceitação dos segredos de assinatura. +/// +/// A validação anterior cobria apenas `APP_ENV=production` e testava só +/// `== null`, então `JWT_SECRET=""` e `APP_ENV=staging` subiam assinando com um +/// valor público — e o `docker-compose.yml` sequer passava a variável. +/// `AUDIT_CHAIN_SECRET` segue exatamente a mesma regra, para a cadeia de hash +/// de `audit_logs`. +void main() { + AppConfig build({ + required String appEnv, + String? jwtSecret, + String? auditChainSecret, + }) => + AppConfig.fromMap({ + 'APP_ENV': appEnv, + 'JWT_SECRET': ?jwtSecret, + 'AUDIT_CHAIN_SECRET': ?auditChainSecret, + }); + + group('JWT_SECRET', () { + test('development sem a variável usa o fallback conhecido', () { + expect( + build(appEnv: 'development').jwtSecret, + AppConfig.developmentJwtSecret, + ); + }); + + test('development aceita um segredo próprio', () { + expect( + build(appEnv: 'development', jwtSecret: 'a' * 64).jwtSecret, + 'a' * 64, + ); + }); + + test('production sem a variável não sobe', () { + expect( + () => build(appEnv: 'production'), + throwsA(isA().having( + (error) => error.message, + 'message', + contains('JWT_SECRET é obrigatório'), + )), + ); + }); + + test('staging sem a variável também não sobe', () { + // A checagem anterior cobria só production. + expect(() => build(appEnv: 'staging'), throwsA(isA())); + }); + + test('string vazia conta como ausente', () { + expect( + () => build(appEnv: 'production', jwtSecret: ''), + throwsA(isA()), + ); + }); + + test('string só de espaços conta como ausente', () { + expect( + () => build(appEnv: 'production', jwtSecret: ' '), + throwsA(isA()), + ); + }); + + test('o segredo de desenvolvimento não pode ser promovido', () { + // Está neste repositório versionado: usá-lo fora de development é assinar + // token com chave pública, e o token carrega papel e microárea. + expect( + () => build( + appEnv: 'production', + jwtSecret: AppConfig.developmentJwtSecret, + ), + throwsA(isA().having( + (error) => error.message, + 'message', + contains('valor de desenvolvimento'), + )), + ); + }); + + test('production com segredo próprio sobe', () { + final config = build( + appEnv: 'production', + jwtSecret: 'b' * 64, + auditChainSecret: 'd' * 64, + ); + + expect(config.jwtSecret, 'b' * 64); + expect(config.isProduction, isTrue); + }); + + test('espaços em volta do segredo são aparados', () { + expect( + build( + appEnv: 'production', + jwtSecret: ' ${'c' * 64} ', + auditChainSecret: 'd' * 64, + ).jwtSecret, + 'c' * 64, + ); + }); + }); + + group('AUDIT_CHAIN_SECRET', () { + // Mesmas regras de JWT_SECRET, testadas de novo porque cada segredo é + // resolvido de forma independente: um poderia estar certo e o outro + // esquecido sem que os testes de JWT_SECRET percebessem. + test('development sem a variável usa o fallback conhecido', () { + expect( + build(appEnv: 'development').auditChainSecret, + AppConfig.developmentAuditChainSecret, + ); + }); + + test('development aceita um segredo próprio', () { + expect( + build(appEnv: 'development', auditChainSecret: 'a' * 64) + .auditChainSecret, + 'a' * 64, + ); + }); + + test('production sem a variável não sobe', () { + expect( + () => build(appEnv: 'production', jwtSecret: 'b' * 64), + throwsA(isA().having( + (error) => error.message, + 'message', + contains('AUDIT_CHAIN_SECRET é obrigatório'), + )), + ); + }); + + test('string vazia conta como ausente', () { + expect( + () => build( + appEnv: 'production', + jwtSecret: 'b' * 64, + auditChainSecret: '', + ), + throwsA(isA()), + ); + }); + + test('o segredo de desenvolvimento não pode ser promovido', () { + expect( + () => build( + appEnv: 'production', + jwtSecret: 'b' * 64, + auditChainSecret: AppConfig.developmentAuditChainSecret, + ), + throwsA(isA().having( + (error) => error.message, + 'message', + contains('valor de desenvolvimento'), + )), + ); + }); + + test('production com segredo próprio sobe, independente do JWT_SECRET', + () { + final config = build( + appEnv: 'production', + jwtSecret: 'b' * 64, + auditChainSecret: 'd' * 64, + ); + + expect(config.auditChainSecret, 'd' * 64); + expect(config.jwtSecret, 'b' * 64); + }); + }); + + group('defaults', () { + test('um ambiente vazio produz a configuração de desenvolvimento', () { + final config = AppConfig.fromMap(const {}); + + expect(config.appEnv, 'development'); + expect(config.mqttBroker, 'localhost:1883'); + expect(config.mqttUseTls, isFalse); + // Gate do auth.developmentLogin: desligado quando não pedido. + expect(config.enableDevLogin, isFalse); + }); + + test('ENABLE_DEV_LOGIN só liga com a string exata "true"', () { + expect(AppConfig.fromMap(const {'ENABLE_DEV_LOGIN': 'true'}).enableDevLogin, isTrue); + expect(AppConfig.fromMap(const {'ENABLE_DEV_LOGIN': 'TRUE'}).enableDevLogin, isFalse); + expect(AppConfig.fromMap(const {'ENABLE_DEV_LOGIN': '1'}).enableDevLogin, isFalse); + }); + }); +} diff --git a/backend/sinalacs_server/test/unit/audit_chain_test.dart b/backend/sinalacs_server/test/unit/audit_chain_test.dart new file mode 100644 index 0000000..6e00aef --- /dev/null +++ b/backend/sinalacs_server/test/unit/audit_chain_test.dart @@ -0,0 +1,292 @@ +import 'package:sinalacs_server/src/application/audit/audit_chain.dart'; +import 'package:sinalacs_server/src/application/audit/audit_chain_verifier.dart'; +import 'package:sinalacs_server/src/application/audit/audit_trail.dart'; +import 'package:test/test.dart'; + +class _ThrowingAuditTrail extends AuditTrail { + @override + Future record(AuditEvent event) async { + throw StateError('trilha de auditoria fora do ar'); + } +} + +/// Simula o que `OrmAuditTrail` faz linha a linha, sem Postgres: monta uma +/// cadeia válida a partir de uma lista de eventos, encadeando cada um ao +/// `entryHash` do anterior. É o que permite montar cenários de adulteração +/// (editar, apagar, reordenar) sobre uma cadeia que nasceu íntegra. +class _ChainBuilder { + _ChainBuilder({required String secret}) : _chain = AuditChain(secret: secret); + + final AuditChain _chain; + final List _entries = []; + + void append({ + String userId = 'user-1', + String actionType = 'read', + String resourceType = 'patient_directory', + String? resourceId, + String result = 'granted', + }) { + final sequence = _entries.length + 1; + final previousHash = + _entries.isEmpty ? AuditChain.genesisHash : _entries.last.entryHash; + final fields = AuditChainFields( + sequence: sequence, + previousHash: previousHash, + userId: userId, + actionType: actionType, + resourceType: resourceType, + resourceId: resourceId, + timestamp: DateTime.utc(2026, 1, sequence), + ipHash: 'hash-de-ip-$sequence', + result: result, + ); + _entries.add(AuditChainEntry( + fields: fields, + entryHash: _chain.computeEntryHash(fields), + )); + } + + List build() => List.of(_entries); +} + +class _FakeReader implements AuditChainReader { + _FakeReader(this.entries); + + final List entries; + + @override + Future> readInOrder() async => entries; +} + +AuditChainFields _withResult(AuditChainFields fields, String result) => + AuditChainFields( + sequence: fields.sequence, + previousHash: fields.previousHash, + userId: fields.userId, + actionType: fields.actionType, + resourceType: fields.resourceType, + resourceId: fields.resourceId, + timestamp: fields.timestamp, + ipHash: fields.ipHash, + result: result, + ); + +AuditChainFields _withSequence(AuditChainFields fields, int sequence) => + AuditChainFields( + sequence: sequence, + previousHash: fields.previousHash, + userId: fields.userId, + actionType: fields.actionType, + resourceType: fields.resourceType, + resourceId: fields.resourceId, + timestamp: fields.timestamp, + ipHash: fields.ipHash, + result: fields.result, + ); + +void main() { + const secret = 'segredo-de-teste'; + + group('AuditChain', () { + test('a mesma entrada produz sempre o mesmo hash', () { + final chain = AuditChain(secret: secret); + final fields = AuditChainFields( + sequence: 1, + previousHash: AuditChain.genesisHash, + userId: 'user-1', + actionType: 'read', + resourceType: 'patient_directory', + resourceId: null, + timestamp: DateTime.utc(2026, 1, 1), + ipHash: 'hash-de-ip', + result: 'granted', + ); + + expect(chain.computeEntryHash(fields), chain.computeEntryHash(fields)); + }); + + test('trocar qualquer campo muda o hash', () { + final chain = AuditChain(secret: secret); + final base = AuditChainFields( + sequence: 1, + previousHash: AuditChain.genesisHash, + userId: 'user-1', + actionType: 'read', + resourceType: 'patient_directory', + resourceId: null, + timestamp: DateTime.utc(2026, 1, 1), + ipHash: 'hash-de-ip', + result: 'granted', + ); + + expect( + chain.computeEntryHash(_withResult(base, 'denied_territory')), + isNot(chain.computeEntryHash(base)), + ); + }); + + test('secrets diferentes produzem hashes diferentes para o mesmo conteúdo', + () { + final fields = AuditChainFields( + sequence: 1, + previousHash: AuditChain.genesisHash, + userId: 'user-1', + actionType: 'read', + resourceType: 'patient_directory', + resourceId: null, + timestamp: DateTime.utc(2026, 1, 1), + ipHash: 'hash-de-ip', + result: 'granted', + ); + + expect( + AuditChain(secret: 'a').computeEntryHash(fields), + isNot(AuditChain(secret: 'b').computeEntryHash(fields)), + ); + }); + }); + + group('AuditChainVerifier', () { + test('uma cadeia vazia é íntegra por vacuidade', () async { + final verifier = + AuditChainVerifier(reader: _FakeReader([]), secret: secret); + + final result = await verifier.verify(); + + expect(result.ok, isTrue); + expect(result.checked, 0); + }); + + test('a gênese usa genesisHash e sequence 1', () async { + final builder = _ChainBuilder(secret: secret)..append(); + final verifier = + AuditChainVerifier(reader: _FakeReader(builder.build()), secret: secret); + + final result = await verifier.verify(); + + expect(result.ok, isTrue); + expect(result.checked, 1); + }); + + test('uma cadeia com várias linhas encadeadas corretamente é íntegra', + () async { + final builder = _ChainBuilder(secret: secret) + ..append(result: 'granted') + ..append(actionType: 'write', resourceType: 'visit', result: 'denied_territory') + ..append(); + final verifier = + AuditChainVerifier(reader: _FakeReader(builder.build()), secret: secret); + + final result = await verifier.verify(); + + expect(result.ok, isTrue); + expect(result.checked, 3); + }); + + test('editar o conteúdo de uma linha é detectado', () async { + final builder = _ChainBuilder(secret: secret) + ..append() + ..append(actionType: 'write', resourceType: 'visit', result: 'denied_territory') + ..append(); + final entries = builder.build(); + + // Adultera a linha do meio SEM recalcular o hash — exatamente o que um + // UPDATE direto no Postgres faria. + final tampered = List.of(entries); + tampered[1] = AuditChainEntry( + fields: _withResult(entries[1].fields, 'granted'), + entryHash: entries[1].entryHash, + ); + + final verifier = + AuditChainVerifier(reader: _FakeReader(tampered), secret: secret); + final result = await verifier.verify(); + + expect(result.ok, isFalse); + expect(result.brokenAtSequence, 2); + expect(result.checked, 1); + expect(result.reason, contains('adulteração')); + }); + + test('apagar uma linha do meio quebra a contiguidade', () async { + final builder = _ChainBuilder(secret: secret) + ..append() + ..append() + ..append(); + final entries = builder.build(); + + final withGap = [entries[0], entries[2]]; // remove a sequence 2 + + final verifier = + AuditChainVerifier(reader: _FakeReader(withGap), secret: secret); + final result = await verifier.verify(); + + expect(result.ok, isFalse); + expect(result.brokenAtSequence, 3); + expect(result.checked, 1); + expect(result.reason, contains('descontínua')); + }); + + test('renumerar uma linha para ocupar o lugar de outra apagada quebra o elo', + () async { + final builder = _ChainBuilder(secret: secret) + ..append() + ..append() + ..append(); + final entries = builder.build(); + + // Apaga a linha 2 e renumera a linha 3 para ocupar o lugar dela, sem + // recalcular previousHash/entryHash — exatamente o que um UPDATE direto + // no Postgres faria sem conhecer o segredo. A sequência fica contígua + // (1, 2), então só o elo denuncia a fraude. + final renumbered = [ + entries[0], + AuditChainEntry( + fields: _withSequence(entries[2].fields, 2), + entryHash: entries[2].entryHash, + ), + ]; + + final verifier = + AuditChainVerifier(reader: _FakeReader(renumbered), secret: secret); + final result = await verifier.verify(); + + expect(result.ok, isFalse); + expect(result.checked, 1); + expect(result.reason, contains('elo quebrado')); + }); + + test('verificar com o segredo errado rejeita uma cadeia legítima', + () async { + final builder = _ChainBuilder(secret: secret)..append()..append(); + final verifier = AuditChainVerifier( + reader: _FakeReader(builder.build()), + secret: 'segredo-errado', + ); + + final result = await verifier.verify(); + + expect(result.ok, isFalse); + expect(result.brokenAtSequence, 1); + expect(result.reason, contains('adulteração')); + }); + }); + + group('AuditTrail.recordSafely', () { + // Regressão: a cadeia de hash não muda o contrato de recordSafely, testado + // a fundo em patient_directory_service_test.dart e + // visit_sync_service_test.dart — uma trilha fora do ar não pode derrubar a + // operação clínica que está tentando auditar. + test('não propaga falha de record', () async { + final trail = _ThrowingAuditTrail(); + + await trail.recordSafely(const AuditEvent( + userId: 'user-1', + actionType: 'read', + resourceType: 'patient_directory', + result: 'granted', + )); + }); + }); +} diff --git a/backend/sinalacs_server/test/unit/patient_directory_service_test.dart b/backend/sinalacs_server/test/unit/patient_directory_service_test.dart new file mode 100644 index 0000000..8eb6ed7 --- /dev/null +++ b/backend/sinalacs_server/test/unit/patient_directory_service_test.dart @@ -0,0 +1,157 @@ +import 'package:sinalacs_server/src/application/audit/audit_trail.dart'; +import 'package:sinalacs_server/src/application/auth/development_auth_service.dart'; +import 'package:sinalacs_server/src/application/patients/patient_directory_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:test/test.dart'; + +/// UUIDs sintéticos do seed de desenvolvimento. +const _acsId = '00000000-0000-4000-8000-000000000002'; +const _microAreaId = '00000000-0000-4000-8000-000000000003'; +const _otherMicroAreaId = '00000000-0000-4000-8000-000000000099'; + +const _acs = AuthenticatedUser( + id: _acsId, + role: UserRole.acs, + microAreaId: _microAreaId, + deviceId: 'acs-device-001', +); + +class FakePatientDirectoryStore implements PatientDirectoryStore { + FakePatientDirectoryStore(this.byMicroArea); + + final Map> byMicroArea; + final List queriedMicroAreas = []; + + @override + Future> listByMicroArea(String microAreaId) async { + queriedMicroAreas.add(microAreaId); + return byMicroArea[microAreaId] ?? const []; + } +} + +/// Trilha de auditoria em memória, no mesmo molde de +/// `visit_sync_service_test.dart`. +class FakeAuditTrail extends AuditTrail { + FakeAuditTrail({this.failOnRecord = false}); + + final bool failOnRecord; + final List events = []; + + @override + Future record(AuditEvent event) async { + if (failOnRecord) throw StateError('trilha de auditoria fora do ar'); + events.add(event); + } +} + +void main() { + late FakePatientDirectoryStore store; + late FakeAuditTrail audit; + late PatientDirectoryService service; + + final pacientesDaArea = [ + const PatientDirectoryEntry( + patientId: '00000000-0000-4000-8000-000000000005', + name: 'Fulano de Tal', + isChronic: true, + chronicConditions: ['hipertensão'], + ), + const PatientDirectoryEntry( + patientId: '00000000-0000-4000-8000-000000000006', + name: 'Ciclana da Silva', + isChronic: false, + chronicConditions: [], + ), + ]; + + setUp(() { + store = FakePatientDirectoryStore({ + _microAreaId: pacientesDaArea, + _otherMicroAreaId: const [ + PatientDirectoryEntry( + patientId: '00000000-0000-4000-8000-000000000009', + name: 'Paciente de Outra Área', + isChronic: false, + chronicConditions: [], + ), + ], + }); + audit = FakeAuditTrail(); + service = PatientDirectoryService(store: store, audit: audit); + }); + + test('devolve só os pacientes da microárea do ACS', () async { + final result = await service.listForAcs(_acs); + + expect(result, hasLength(2)); + expect(result.map((p) => p.patientId), containsAll([ + '00000000-0000-4000-8000-000000000005', + '00000000-0000-4000-8000-000000000006', + ])); + expect(store.queriedMicroAreas, [_microAreaId]); + }); + + test('recusa quando o papel não é ACS', () async { + const patient = AuthenticatedUser( + id: '00000000-0000-4000-8000-000000000001', + role: UserRole.patient, + microAreaId: _microAreaId, + deviceId: 'patient-device-001', + ); + + expect( + () => service.listForAcs(patient), + throwsA(isA()), + ); + }); + + test('recusa quando o ACS não tem microárea', () async { + const acsSemArea = AuthenticatedUser( + id: _acsId, + role: UserRole.acs, + microAreaId: null, + deviceId: 'acs-device-001', + ); + + expect( + () => service.listForAcs(acsSemArea), + throwsA(isA()), + ); + }); + + test('o resultado não carrega dado além de nome e condições crônicas', () async { + // Minimização (spec/lgpd_design.md:364): a visita de rotina só precisa de + // nome e condições crônicas para escolher o paciente. `MicroAreaPatient` + // não declara `emergencyContact` nem qualquer outro campo — a asserção é + // no próprio construtor: se um campo a mais fosse adicionado ao modelo, + // este teste continuaria passando sem avisar, então o que prova + // minimização aqui é a ausência do campo no `.spy.yaml`, não este teste. + final result = await service.listForAcs(_acs); + + expect(result.first.patientId, isNotEmpty); + expect(result.first.name, isNotEmpty); + }); + + test('a leitura grava uma linha de auditoria sem enumerar os pacientes', () async { + await service.listForAcs(_acs); + + expect(audit.events, hasLength(1)); + final event = audit.events.single; + expect(event.actionType, 'read'); + expect(event.resourceType, 'patient_directory'); + expect(event.result, 'granted'); + expect(event.userId, _acsId); + // Enumerar os pacientes lidos AQUI recriaria o prontuário dentro do + // próprio log de auditoria — por isso o evento não carrega `resourceId`. + expect(event.resourceId, isNull); + }); + + test('uma trilha de auditoria fora do ar não impede a listagem', () async { + audit = FakeAuditTrail(failOnRecord: true); + service = PatientDirectoryService(store: store, audit: audit); + + final result = await service.listForAcs(_acs); + + expect(result, hasLength(2)); + }); +} diff --git a/backend/sinalacs_server/test/unit/sync_fsm_test.dart b/backend/sinalacs_server/test/unit/sync_fsm_test.dart index 167142c..7130dca 100644 --- a/backend/sinalacs_server/test/unit/sync_fsm_test.dart +++ b/backend/sinalacs_server/test/unit/sync_fsm_test.dart @@ -34,5 +34,34 @@ void main() { expect(fsm.state, SyncState.conflict); }); + + test('syncRejected registra recusa definitiva', () { + final fsm = SyncFsm(); + + fsm.trigger(SyncEvent.save); + fsm.trigger(SyncEvent.syncRejected); + + expect(fsm.state, SyncState.rejected); + }); + + test('rejected é terminal: networkUp não tira a visita de lá', () { + final fsm = SyncFsm(); + + fsm.trigger(SyncEvent.save); + fsm.trigger(SyncEvent.syncRejected); + fsm.trigger(SyncEvent.networkUp); + + expect(fsm.state, SyncState.rejected); + }); + + test('rejected é terminal: syncStart não tira a visita de lá', () { + final fsm = SyncFsm(); + + fsm.trigger(SyncEvent.save); + fsm.trigger(SyncEvent.syncRejected); + fsm.trigger(SyncEvent.syncStart); + + expect(fsm.state, SyncState.rejected); + }); }); } diff --git a/backend/sinalacs_server/test/unit/visit_sync_service_test.dart b/backend/sinalacs_server/test/unit/visit_sync_service_test.dart new file mode 100644 index 0000000..ef1c20b --- /dev/null +++ b/backend/sinalacs_server/test/unit/visit_sync_service_test.dart @@ -0,0 +1,287 @@ +import 'package:serverpod/serverpod.dart' show UuidValue; +import 'package:sinalacs_server/src/application/audit/audit_trail.dart'; +import 'package:sinalacs_server/src/application/auth/development_auth_service.dart'; +import 'package:sinalacs_server/src/application/visits/visit_sync_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:test/test.dart'; + +/// UUIDs sintéticos do seed de desenvolvimento. +const _acsId = '00000000-0000-4000-8000-000000000002'; +const _otherAcsId = '00000000-0000-4000-8000-000000000012'; +const _microAreaId = '00000000-0000-4000-8000-000000000003'; +const _otherMicroAreaId = '00000000-0000-4000-8000-000000000099'; +const _patientId = '00000000-0000-4000-8000-000000000001'; +const _outroTerritorioPatientId = '00000000-0000-4000-8000-000000000009'; +const _localId = '00000000-0000-4000-8000-0000000000a1'; + +/// Store em memória, com a mesma unicidade de `localId` que o índice do banco. +class FakeVisitStore implements VisitStore { + FakeVisitStore({ + Map microAreaByPatient = const {_patientId: _microAreaId}, + }) : _microAreaByPatient = microAreaByPatient; + + final Map _microAreaByPatient; + final Map rows = {}; + + @override + Future findByLocalId(String localId) async => rows[localId]; + + @override + Future insert(Visit visit) async { + rows[visit.localId.uuid] = visit; + return visit; + } + + @override + Future update(Visit visit) async { + rows[visit.localId.uuid] = visit; + return visit; + } + + @override + Future microAreaOfPatient(UuidValue patientId) async { + final microAreaId = _microAreaByPatient[patientId.uuid]; + return microAreaId == null ? null : UuidValue.fromString(microAreaId); + } +} + +/// Trilha de auditoria em memória. `failOnRecord` simula uma trilha fora do +/// ar, para provar que `recordSafely` (herdado, não reescrito aqui) não +/// derruba a sincronização. +class FakeAuditTrail extends AuditTrail { + FakeAuditTrail({this.failOnRecord = false}); + + final bool failOnRecord; + final List events = []; + + @override + Future record(AuditEvent event) async { + if (failOnRecord) throw StateError('trilha de auditoria fora do ar'); + events.add(event); + } +} + +const _acs = AuthenticatedUser( + id: _acsId, + role: UserRole.acs, + microAreaId: _microAreaId, + deviceId: 'acs-device-001', +); + +const _patient = AuthenticatedUser( + id: _patientId, + role: UserRole.patient, + microAreaId: _microAreaId, + deviceId: 'patient-device-001', +); + +VisitSyncEntry entry({ + int version = 0, + String status = 'realizada', + String patientId = _patientId, +}) { + return VisitSyncEntry( + localId: _localId, + patientId: patientId, + scheduledAt: DateTime.utc(2026, 9, 11, 9), + completedAt: DateTime.utc(2026, 9, 11, 10), + status: status, + riskLevelBefore: RiskLevel.red, + riskLevelAfter: RiskLevel.yellow, + notes: const {'campo': 'sem intercorrências'}, + version: version, + ); +} + +void main() { + late FakeVisitStore store; + late FakeAuditTrail audit; + late VisitSyncService service; + + setUp(() { + store = FakeVisitStore(); + audit = FakeAuditTrail(); + service = VisitSyncService( + store: store, + audit: audit, + clock: () => DateTime.utc(2026, 9, 11, 12), + ); + }); + + test('grava uma visita nova e devolve synced', () async { + final results = await service.sync(user: _acs, entries: [entry()]); + + expect(results.single.syncStatus, SyncStatus.synced); + expect(results.single.serverVersion, 1); + expect(store.rows[_localId]?.status, 'realizada'); + }); + + test('reenvio do mesmo estado não duplica a visita', () async { + // A rede caiu depois de o servidor gravar e o dispositivo não viu a + // resposta: o retry precisa ser idempotente, não virar uma segunda visita. + await service.sync(user: _acs, entries: [entry()]); + final retry = await service.sync(user: _acs, entries: [entry(version: 1)]); + + expect(retry.single.syncStatus, SyncStatus.synced); + expect(retry.single.serverVersion, 1); + expect(store.rows, hasLength(1)); + }); + + test('atualiza a visita quando o dispositivo parte da versão corrente', () async { + await service.sync(user: _acs, entries: [entry()]); + + final update = await service.sync( + user: _acs, + entries: [entry(version: 1, status: 'paciente ausente')], + ); + + // Mesma versão que a do servidor é reenvio, não atualização; para atualizar, + // o dispositivo parte de version = servidor - 1 após incrementar localmente. + expect(update.single.syncStatus, SyncStatus.synced); + expect(store.rows[_localId]?.status, 'realizada'); + }); + + test('devolve conflito sem sobrescrever quando as versões divergem', () async { + await service.sync(user: _acs, entries: [entry()]); + + final conflicted = await service.sync( + user: _acs, + entries: [entry(version: 7, status: 'recusou atendimento')], + ); + + expect(conflicted.single.syncStatus, SyncStatus.conflict); + expect(conflicted.single.serverVersion, 1); + // O que estava no servidor continua intacto: conflito não é sobrescrita. + expect(store.rows[_localId]?.status, 'realizada'); + }); + + test('recusa sincronizar visita registrada por outro agente', () async { + await service.sync(user: _acs, entries: [entry()]); + + const otherAcs = AuthenticatedUser( + id: _otherAcsId, + role: UserRole.acs, + microAreaId: _microAreaId, + deviceId: 'acs-device-002', + ); + final results = await service.sync(user: otherAcs, entries: [entry(version: 1)]); + + // Terminal: o dono do registro não muda com uma próxima tentativa. + expect(results.single.syncStatus, SyncStatus.rejected); + expect(store.rows[_localId]?.acsId, UuidValue.fromString(_acsId)); + }); + + test('somente ACS territorializado sincroniza visitas', () async { + expect( + () => service.sync(user: _patient, entries: [entry()]), + throwsA(isA()), + ); + + const acsSemArea = AuthenticatedUser( + id: _acsId, + role: UserRole.acs, + microAreaId: null, + deviceId: 'acs-device-001', + ); + expect( + () => service.sync(user: acsSemArea, entries: [entry()]), + throwsA(isA()), + ); + }); + + test('identificador inválido vira rejected, não derruba o lote', () async { + final results = await service.sync(user: _acs, entries: [ + VisitSyncEntry( + localId: 'nao-e-uuid', + patientId: _patientId, + scheduledAt: DateTime.utc(2026, 9, 11, 9), + status: 'realizada', + riskLevelBefore: RiskLevel.green, + notes: const {}, + version: 0, + ), + entry(), + ]); + + // Terminal: um UUID malformado na origem não vira válido reenviando. + expect(results.first.syncStatus, SyncStatus.rejected); + // A visita válida do mesmo lote segue adiante. + expect(results.last.syncStatus, SyncStatus.synced); + }); + + group('territorialização (INV-01)', () { + setUp(() { + // Este grupo precisa de um segundo paciente, fora da microárea do ACS — + // o `store` padrão do `setUp` externo só conhece `_patientId`. + store = FakeVisitStore(microAreaByPatient: { + _patientId: _microAreaId, + _outroTerritorioPatientId: _otherMicroAreaId, + }); + audit = FakeAuditTrail(); + service = VisitSyncService( + store: store, + audit: audit, + clock: () => DateTime.utc(2026, 9, 11, 12), + ); + }); + + test('recusa visita para paciente de outra microárea, sem gravar nada', () async { + final results = await service.sync( + user: _acs, + entries: [entry(patientId: _outroTerritorioPatientId)], + ); + + // Terminal: o território não muda com uma próxima tentativa. + expect(results.single.syncStatus, SyncStatus.rejected); + expect(store.rows, isEmpty); + // A mensagem não pode citar a microárea alheia nem o nome do paciente. + expect(results.single.message, isNot(contains(_otherMicroAreaId))); + }); + + test('recusa por território grava auditoria com o paciente envolvido', () async { + await service.sync(user: _acs, entries: [entry(patientId: _outroTerritorioPatientId)]); + + expect(audit.events, hasLength(1)); + final event = audit.events.single; + expect(event.result, 'denied_territory'); + expect(event.resourceType, 'visit'); + expect(event.resourceId, UuidValue.fromString(_outroTerritorioPatientId).uuid); + expect(event.userId, _acsId); + }); + + test('uma recusa por território não descarta as demais visitas do lote', () async { + final results = await service.sync(user: _acs, entries: [ + entry(patientId: _outroTerritorioPatientId), + entry(), + ]); + + expect(results.first.syncStatus, SyncStatus.rejected); + expect(results.last.syncStatus, SyncStatus.synced); + }); + + test('paciente inexistente vira error sem gerar auditoria de território', () async { + final results = await service.sync( + user: _acs, + entries: [entry(patientId: '00000000-0000-4000-8000-00000000dead')], + ); + + expect(results.single.syncStatus, SyncStatus.error); + // Não é necessariamente espionagem territorial — pode ser um localId + // órfão de um seed antigo. Só a recusa POR TERRITÓRIO é auditada. + expect(audit.events, isEmpty); + }); + + test('uma trilha de auditoria fora do ar não impede a recusa territorial', () async { + audit = FakeAuditTrail(failOnRecord: true); + service = VisitSyncService(store: store, audit: audit, clock: () => DateTime.utc(2026, 9, 11, 12)); + + final results = await service.sync( + user: _acs, + entries: [entry(patientId: _outroTerritorioPatientId)], + ); + + // A operação clínica (recusar a visita) não pode depender da auditoria. + expect(results.single.syncStatus, SyncStatus.rejected); + }); + }); +} diff --git a/docker-compose.yml b/docker-compose.yml index 19e8f47..82e9d63 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,15 +5,47 @@ services: image: postgres:15-alpine container_name: sinalacs-postgres environment: - POSTGRES_USER: sinalacs_user - POSTGRES_PASSWORD: strongpassword - POSTGRES_DB: sinalacs_db + POSTGRES_USER: ${POSTGRES_USER:-sinalacs_user} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?defina em .env — rode ./scripts/dev/bootstrap_env.sh} + POSTGRES_DB: ${POSTGRES_DB:-sinalacs_db} ports: - "5432:5432" volumes: - ./pg_data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U sinalacs_user -d sinalacs_db"] + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-sinalacs_user} -d ${POSTGRES_DB:-sinalacs_db}"] + interval: 5s + timeout: 5s + retries: 5 + + # Banco do harness de teste do Serverpod. Fica atrás de um profile porque não + # faz parte da stack da aplicação: + # + # docker compose --profile test up -d postgres-test + # cd backend/sinalacs_server && dart test + # + # Porta, nome e usuário seguem backend/sinalacs_server/config/test.yaml, e a + # senha é a mesma que scripts/dev/bootstrap_env.sh grava em + # config/passwords.yaml — se divergirem, o Serverpod falha ao carregar a + # config e chama exit(1), que não dá flush no stdout: a suíte morre com exit 1 + # e nenhuma linha de log. + # + # Substitui backend/sinalacs_server/docker-compose.yaml, o template do + # Serverpod que carregava quatro senhas em texto claro no git. + postgres-test: + image: postgres:15-alpine + container_name: sinalacs-postgres-test + profiles: [test] + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ${TEST_DATABASE_PASSWORD:?defina em .env — rode ./scripts/dev/bootstrap_env.sh} + POSTGRES_DB: sinalacs_test + ports: + - "9090:5432" + # Sem volume: o banco de teste é descartável, e o harness aplica as + # migrações e reverte a cada caso. + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d sinalacs_test"] interval: 5s timeout: 5s retries: 5 @@ -38,8 +70,11 @@ services: container_name: sinalacs-mosquitto-init user: root environment: - MQTT_BACKEND_PASSWORD: ${MQTT_BACKEND_PASSWORD:-development-backend-password} - MQTT_ACS_PASSWORD: ${MQTT_ACS_PASSWORD:-development-acs-password} + MQTT_BACKEND_PASSWORD: ${MQTT_BACKEND_PASSWORD:?defina em .env — rode ./scripts/dev/bootstrap_env.sh} + MQTT_ACS_PASSWORD: ${MQTT_ACS_PASSWORD:?defina em .env — rode ./scripts/dev/bootstrap_env.sh} + # SANs adicionais no certificado do broker, para validar em aparelho + # físico na LAN: MQTT_CERT_SAN_EXTRA='IP:192.168.0.10' docker compose up + MQTT_CERT_SAN_EXTRA: ${MQTT_CERT_SAN_EXTRA:-} volumes: - ./infra/docker/mosquitto/init.sh:/init.sh:ro - ./infra/docker/mosquitto/runtime:/mosquitto/runtime @@ -63,20 +98,26 @@ services: SERVERPOD_APPLY_MIGRATIONS: "true" SERVERPOD_DATABASE_HOST: postgres SERVERPOD_DATABASE_PORT: "5432" - SERVERPOD_DATABASE_NAME: sinalacs_db - SERVERPOD_DATABASE_USER: sinalacs_user - SERVERPOD_DATABASE_PASSWORD: strongpassword + SERVERPOD_DATABASE_NAME: ${POSTGRES_DB:-sinalacs_db} + SERVERPOD_DATABASE_USER: ${POSTGRES_USER:-sinalacs_user} + SERVERPOD_DATABASE_PASSWORD: ${POSTGRES_PASSWORD:?defina em .env — rode ./scripts/dev/bootstrap_env.sh} SERVERPOD_DATABASE_REQUIRE_SSL: "false" SERVERPOD_REDIS_ENABLED: "false" # O Insights escuta 8081 por padrão, que aqui é o dashboard do Traefik. SERVERPOD_INSIGHTS_SERVER_PORT: "8083" MQTT_BROKER: mosquitto:8883 MQTT_USERNAME: backend - MQTT_PASSWORD: ${MQTT_BACKEND_PASSWORD:-development-backend-password} + MQTT_PASSWORD: ${MQTT_BACKEND_PASSWORD:?defina em .env — rode ./scripts/dev/bootstrap_env.sh} MQTT_USE_TLS: "true" MQTT_CA_CERT_PATH: /app/certs/ca.crt - APP_ENV: development - ENABLE_DEV_LOGIN: "true" + # Sem esta variável o servidor caía num segredo de desenvolvimento fixo e + # público, com o qual qualquer pessoa forjaria um token de ACS. + JWT_SECRET: ${JWT_SECRET:?defina em .env — rode ./scripts/dev/bootstrap_env.sh} + # Chave da cadeia de hash de audit_logs — segredo próprio, nunca derivado + # do JWT_SECRET (rotacionar um não pode invalidar o outro em silêncio). + AUDIT_CHAIN_SECRET: ${AUDIT_CHAIN_SECRET:?defina em .env — rode ./scripts/dev/bootstrap_env.sh} + APP_ENV: ${APP_ENV:-development} + ENABLE_DEV_LOGIN: "${ENABLE_DEV_LOGIN:-true}" ports: - "8080:8080" volumes: @@ -91,13 +132,13 @@ services: serverpod: condition: service_healthy environment: - PGPASSWORD: strongpassword + PGPASSWORD: ${POSTGRES_PASSWORD:?defina em .env — rode ./scripts/dev/bootstrap_env.sh} volumes: - ./backend/sinalacs_server/lib/src/infrastructure/database/seeds:/seeds:ro entrypoint: - /bin/sh - -c - - psql -v ON_ERROR_STOP=1 -h postgres -U sinalacs_user -d sinalacs_db -f /seeds/development.sql + - psql -v ON_ERROR_STOP=1 -h postgres -U ${POSTGRES_USER:-sinalacs_user} -d ${POSTGRES_DB:-sinalacs_db} -f /seeds/development.sql traefik: image: traefik:v2.10 diff --git a/docs/screenshots/admin/01-login.png b/docs/screenshots/admin/01-login.png new file mode 100644 index 0000000..4a714df Binary files /dev/null and b/docs/screenshots/admin/01-login.png differ diff --git a/docs/screenshots/admin/02-indicadores.png b/docs/screenshots/admin/02-indicadores.png new file mode 100644 index 0000000..4d371c2 Binary files /dev/null and b/docs/screenshots/admin/02-indicadores.png differ diff --git a/docs/screenshots/admin/03-microareas.png b/docs/screenshots/admin/03-microareas.png new file mode 100644 index 0000000..7aed8ca Binary files /dev/null and b/docs/screenshots/admin/03-microareas.png differ diff --git a/docs/screenshots/admin/04-alertas.png b/docs/screenshots/admin/04-alertas.png new file mode 100644 index 0000000..7d6ba4d Binary files /dev/null and b/docs/screenshots/admin/04-alertas.png differ diff --git a/docs/screenshots/admin/05-auditoria.png b/docs/screenshots/admin/05-auditoria.png new file mode 100644 index 0000000..b042a8a Binary files /dev/null and b/docs/screenshots/admin/05-auditoria.png differ diff --git a/docs/screenshots/admin/06-android-retrato.png b/docs/screenshots/admin/06-android-retrato.png new file mode 100644 index 0000000..e5d5c46 Binary files /dev/null and b/docs/screenshots/admin/06-android-retrato.png differ diff --git a/docs/screenshots/admin/07-android-paisagem.png b/docs/screenshots/admin/07-android-paisagem.png new file mode 100644 index 0000000..03587f7 Binary files /dev/null and b/docs/screenshots/admin/07-android-paisagem.png differ diff --git a/docs/screenshots/admin/08-tablet-rail.png b/docs/screenshots/admin/08-tablet-rail.png new file mode 100644 index 0000000..948bd34 Binary files /dev/null and b/docs/screenshots/admin/08-tablet-rail.png differ diff --git a/docs/telas-acs.md b/docs/telas-acs.md index 33ab384..1c902ef 100644 --- a/docs/telas-acs.md +++ b/docs/telas-acs.md @@ -22,7 +22,7 @@ Exibe a microárea, quantidade de pacientes e status do cache. A ação de atual ![Fila ordenada por risco clínico](screenshots/acs/02-dashboard.png) -A fila apresenta os riscos vermelho, amarelo e verde com borda e ação contextual. A ordem mantém a prioridade clínica e a cor é reservada para a gravidade. +A fila apresenta os riscos vermelho, amarelo e verde com borda e ação contextual. A ordem mantém a prioridade clínica e a cor é reservada para a gravidade. Por isso avisos de infraestrutura — sem conexão com a central, visitas não sendo salvas — usam o azul de destaque, e não vermelho ou amarelo: um card vermelho de falha técnica competiria com o alerta vermelho de um paciente na mesma lista. Quando a falha de conexão é transitória, o card ainda ganha um botão "Tentar agora" — a reconexão automática já está tentando sozinha em segundo plano, mas quem vê o sinal voltar não precisa esperar o intervalo. ## Mapa de pacientes diff --git a/docs/telas-admin.md b/docs/telas-admin.md new file mode 100644 index 0000000..50127ec --- /dev/null +++ b/docs/telas-admin.md @@ -0,0 +1,65 @@ +# Telas do Backoffice Admin + +Documentação visual do protótipo Flutter do backoffice administrativo (`apps/admin`). Assim como `docs/telas-acs.md` e `docs/telas-paciente.md`, as imagens abaixo foram capturadas rodando o app com dados sintéticos: as cinco primeiras em Flutter Web (`flutter run -d web-server`), as da seção *Layout em celular* num emulador Android (`flutter run -d emulator-5554`). + +## Navegação + +O backoffice é desktop-first (`spec/PRD_system.md` §2.1): acima de `AdminBreakpoints.rail` (640dp) a navegação usa `NavigationRail` lateral; abaixo disso, `NavigationBar` inferior — mesmo `ThemeData`, só muda o container de navegação. Os quatro destinos são **Indicadores**, **Microáreas**, **Alertas** e **Auditoria**. Os pontos de quebra são constantes nomeadas em [`apps/admin/lib/app/admin_layout.dart`](../apps/admin/lib/app/admin_layout.dart). + +## Login institucional (ambiente de desenvolvimento) + +![Login do backoffice](screenshots/admin/01-login.png) + +Formulário local (matrícula/CNS e senha pré-preenchidos, não validados — o botão avança independente do que está digitado) no mesmo padrão visual do login do ACS. Um banner fixo deixa explícito que não há autenticação institucional real (SSO/gov.br) nesta etapa. + +Paciente e ACS já autenticam de verdade contra `auth.developmentLogin`; o admin não, porque esse endpoint hoje só aceita `role: patient` ou `role: acs` — não existe usuário fixo de desenvolvimento para `admin` (ver `backend/sinalacs_server/lib/src/endpoints/auth_endpoint.dart`). Ligar isso de verdade exige uma mudança no backend, fora do escopo desta issue. + +## Painel de indicadores + +![Painel de indicadores da UBS](screenshots/admin/02-indicadores.png) + +Contadores por `RiskLevel` (vermelho/amarelo/verde), alertas vermelhos abertos vs. reconhecidos e o TMRAV (Tempo Médio de Resposta a Alerta Vermelho, a métrica North Star do PRD). Dados mockados atrás de `AdminDataSource`; cor usada exclusivamente como sinal clínico. + +## Microáreas e vínculo ACS + +![Listagem de microáreas e ACS vinculado](screenshots/admin/03-microareas.png) + +Lista somente leitura das microáreas da UBS com o ACS vinculado e seu status. Edição de vínculo fica para uma issue futura, como definido no escopo. + +## Alertas da UBS + +![Consulta de alertas filtrável](screenshots/admin/04-alertas.png) + +Lista de alertas filtrável por microárea e status. Não há nenhum controle de reclassificação de risco — a classificação é determinística e não alterável por intervenção humana (INV-02 do PRD). + +## Logs de auditoria + +![Logs de auditoria somente leitura](screenshots/admin/05-auditoria.png) + +Log somente leitura. Toda visita às telas de Microáreas, Alertas e Auditoria registra uma entrada própria via `AdminDataSource.recordAccess`, simulando o requisito do PRD §4.2.2 de que o acesso do Administrador também é auditado. + +## Layout em celular (Android) + +O app roda em Android desde que a plataforma foi adicionada. Mobile é **complemento do desktop, não substituição**: o layout com rail continua sendo o padrão, e abaixo de `AdminBreakpoints.stacked` (480dp) o que mudaria de significado ao ser espremido passa a empilhar. + +![Backoffice em celular, retrato](screenshots/admin/06-android-retrato.png) + +Em retrato: `NavigationBar` inferior com os quatro destinos; o selo "Acesso auditado" do cabeçalho vira ícone (mantendo o rótulo para leitores de tela, via `Semantics`); e o `Chip` de vínculo do ACS desce para baixo da descrição em vez de disputar largura com o título — antes, como `trailing` de um `ListTile`, "Sem ACS ativo" comia ~140dp dos ~320dp úteis. + +![Backoffice em celular, paisagem](screenshots/admin/07-android-paisagem.png) + +Em paisagem o aparelho passa dos 640dp e o `NavigationRail` volta, junto com o chip e com os dois filtros de Alertas lado a lado. Sobram ~288dp de altura para quatro destinos rotulados: cabe por poucos pixels com fonte padrão e estoura a partir de ~150%, então o rail usa `scrollable: true`. A rotação preserva o destino e os filtros selecionados, porque o `AndroidManifest.xml` declara `configChanges` para `orientation` e `fontScale`. + +![Backoffice em tablet](screenshots/admin/08-tablet-rail.png) + +Em tablet (AVD `Medium_Tablet`, 2560x1600) o layout é indistinguível do web: rail lateral, chip no cabeçalho, filtros lado a lado. É essa a checagem de que o desktop-first não regrediu ao ganhar o layout compacto. + +Validado no emulador em retrato, paisagem, em tablet e com a fonte do sistema a 200% (WCAG 1.4.4), sem nenhum estouro de layout. A régua automatizada correspondente está em `apps/admin/test/responsive_layout_test.dart` e `text_scale_test.dart`; `apps/admin/integration_test/` repete o percurso no runtime real do Android e é **hermético** — não precisa da stack Docker, ao contrário dos apps ACS e do paciente. + +## Referências + +- [Implementação Flutter](../apps/admin/lib/app/app.dart) +- [Pontos de quebra e altura do cabeçalho](../apps/admin/lib/app/admin_layout.dart) +- [Camada de dados](../apps/admin/lib/core/data/admin_data_source.dart) +- [Protótipos de referência](../spec/ui_acs) (linguagem visual — não há protótipo HTML específico do admin ainda) +- [Guia visual](../spec/ui_design.md) diff --git a/infra/docker/mosquitto/aclfile b/infra/docker/mosquitto/aclfile index b132282..9a755fc 100644 --- a/infra/docker/mosquitto/aclfile +++ b/infra/docker/mosquitto/aclfile @@ -1,6 +1,12 @@ user backend topic readwrite sinalacs/v1/# +# O nome do usuário é histórico ("area-12"), mas o tópico precisa ser o UUID da +# microárea do seed de desenvolvimento: o backend publica em +# sinalacs/v1/microareas//alerts, e o microAreaId vem do token +# emitido por auth.developmentLogin, que usa o UUID fixo do seed +# (development.sql / auth_endpoint.dart). Com "area-12" aqui, o app do ACS era +# negado pelo broker exatamente no tópico onde o alerta vermelho chega. user acs-area-12 -topic read sinalacs/v1/microareas/area-12/alerts +topic read sinalacs/v1/microareas/00000000-0000-4000-8000-000000000003/alerts topic write sinalacs/v1/alerts/+/acks \ No newline at end of file diff --git a/infra/docker/mosquitto/init.sh b/infra/docker/mosquitto/init.sh index 3c23d0f..b6d0272 100644 --- a/infra/docker/mosquitto/init.sh +++ b/infra/docker/mosquitto/init.sh @@ -7,27 +7,96 @@ password_file="$runtime_dir/passwordfile" mkdir -p "$certs_dir" -if [ ! -f "$certs_dir/ca.crt" ]; then - openssl req -x509 -newkey rsa:2048 -nodes -days 7 \ +# Nomes pelos quais o broker é alcançado. O certificado PRECISA trazer todos +# como subjectAltName: `dart:io` valida o hostname contra o certificado, e o +# emulador Android alcança o host por 10.0.2.2 — não por "mosquitto", que só +# resolve dentro da rede do Compose. Sem SAN, a handshake TLS do app falha. +# +# Para validar em aparelho físico na LAN, exporte o IP da máquina: +# MQTT_CERT_SAN_EXTRA='IP:192.168.0.10' docker compose up +san='DNS:mosquitto,DNS:localhost,IP:127.0.0.1,IP:10.0.2.2' +if [ -n "${MQTT_CERT_SAN_EXTRA:-}" ]; then + san="$san,$MQTT_CERT_SAN_EXTRA" +fi + +ca_days=3650 +server_days=397 + +# Renova em vez de só criar. A versão anterior era idempotente por +# `if [ ! -f ]` com validade de 7 dias, o que significa que um certificado +# vencido nunca era substituído: o TLS parava de funcionar sozinho depois de +# uma semana, sem ninguém ter mexido em nada. +needs_ca=0 +if [ ! -f "$certs_dir/ca.crt" ] || [ ! -f "$certs_dir/ca.key" ]; then + needs_ca=1 +elif ! openssl x509 -in "$certs_dir/ca.crt" -noout -checkend 2592000 >/dev/null 2>&1; then + echo 'CA de desenvolvimento expira em menos de 30 dias; regerando.' + needs_ca=1 +fi + +if [ "$needs_ca" -eq 1 ]; then + echo 'Gerando CA de desenvolvimento...' + openssl req -x509 -newkey rsa:2048 -nodes -days "$ca_days" \ -keyout "$certs_dir/ca.key" \ -out "$certs_dir/ca.crt" \ -subj '/CN=sinalacs-local-ca' + # A CA mudou, então o certificado do servidor assinado pela antiga não serve. + rm -f "$certs_dir/server.crt" "$certs_dir/server.key" "$certs_dir/ca.srl" +fi + +needs_server=0 +if [ ! -f "$certs_dir/server.crt" ] || [ ! -f "$certs_dir/server.key" ]; then + needs_server=1 +elif ! openssl x509 -in "$certs_dir/server.crt" -noout -checkend 86400 >/dev/null 2>&1; then + echo 'Certificado do broker expira em menos de 24h; regerando.' + needs_server=1 +elif ! openssl x509 -in "$certs_dir/server.crt" -noout -ext subjectAltName 2>/dev/null \ + | grep -q '10.0.2.2'; then + echo 'Certificado do broker sem o SAN esperado; regerando.' + needs_server=1 +fi + +if [ "$needs_server" -eq 1 ]; then + echo "Gerando certificado do broker (SAN: $san)..." + ext_file="$certs_dir/server.ext" + cat > "$ext_file" <&2; exit 2 ;; + esac +done + +if ! command -v openssl >/dev/null 2>&1; then + echo 'erro: openssl não encontrado — é ele que gera os segredos.' >&2 + exit 1 +fi + +secret() { openssl rand -hex 32; } + +# --- .env ------------------------------------------------------------------ +if [[ -f "$env_file" && "$force" -eq 0 ]]; then + echo ".env já existe — preservado. Use --force para recriar." +else + if [[ ! -f "$env_example" ]]; then + echo "erro: $env_example não existe." >&2 + exit 1 + fi + + if [[ -f "$env_file" ]]; then + backup="$env_file.bak.$(date +%Y%m%d%H%M%S)" + cp "$env_file" "$backup" + chmod 600 "$backup" + echo " .env anterior salvo em $(basename "$backup")" + fi + + # Preenche cada chave de segredo vazia do modelo; o resto passa intacto, + # para que comentários e defaults não-secretos continuem valendo. + POSTGRES_PASSWORD="$(secret)" \ + TEST_DATABASE_PASSWORD="$(secret)" \ + MQTT_BACKEND_PASSWORD="$(secret)" \ + MQTT_ACS_PASSWORD="$(secret)" \ + JWT_SECRET="$(secret)" \ + AUDIT_CHAIN_SECRET="$(secret)" \ + awk ' + { + split($0, kv, "=") + key = kv[1] + # Só mexe em linha "CHAVE=" sem valor, e só nas chaves de segredo. + if ($0 ~ /^[A-Z_]+=$/ && ENVIRON[key] != "") { + print key "=" ENVIRON[key] + } else { + print + } + } + ' "$env_example" > "$env_file" + + chmod 600 "$env_file" + echo " .env gerado (POSTGRES_PASSWORD, TEST_DATABASE_PASSWORD," + echo " MQTT_BACKEND_PASSWORD, MQTT_ACS_PASSWORD, JWT_SECRET," + echo " AUDIT_CHAIN_SECRET)" +fi + +# --- config/passwords.yaml ------------------------------------------------- +# O valor PRECISA ser o mesmo TEST_DATABASE_PASSWORD do .env: é a senha do +# Postgres de teste (profile `test`, porta 9090) que o harness do Serverpod +# espera segundo config/test.yaml. Divergir traz de volta a falha silenciosa. +test_password="$(grep -E '^TEST_DATABASE_PASSWORD=' "$env_file" | head -1 | cut -d= -f2-)" +if [[ -z "$test_password" ]]; then + echo 'erro: TEST_DATABASE_PASSWORD está vazio no .env.' >&2 + exit 1 +fi + +if [[ -f "$passwords_file" && "$force" -eq 0 ]]; then + echo "config/passwords.yaml já existe — preservado. Use --force para recriar." + stored="$(awk '/^test:/{f=1;next} /^[a-z]+:/{f=0} f && /database:/{print $2}' "$passwords_file" | tr -d "'\"")" + if [[ "$stored" != "$test_password" ]]; then + echo ' AVISO: a senha em passwords.yaml[test] NÃO bate com TEST_DATABASE_PASSWORD do .env.' >&2 + echo ' O harness de teste vai falhar sem log. Rode com --force para alinhar.' >&2 + fi +else + mkdir -p "$(dirname "$passwords_file")" + cat > "$passwords_file" </dev/null 2>&1; then + echo 'erro: flutter não encontrado no PATH.' >&2 + exit 1 +fi + +if [[ ! -f "$env_file" ]]; then + echo 'erro: .env não existe.' >&2 + echo 'Rode ./scripts/dev/bootstrap_env.sh para gerar a configuração local.' >&2 + exit 1 +fi + +# Lê só a chave necessária, em vez de `source .env`: não há motivo para empurrar +# POSTGRES_PASSWORD e JWT_SECRET para dentro do Gradle e do daemon do Flutter. +# Uma variável já exportada tem precedência — é como se testa o caminho de +# credencial recusada sem editar o .env. +mqtt_password="${MQTT_ACS_PASSWORD:-$(grep -E '^MQTT_ACS_PASSWORD=' "$env_file" | head -1 | cut -d= -f2-)}" +# Usuário criado por infra/docker/mosquitto/init.sh. +mqtt_user="${MQTT_ACS_USER:-acs-area-12}" +google_maps_api_key="${GOOGLE_MAPS_API_KEY:-$(grep -E '^GOOGLE_MAPS_API_KEY=' "$env_file" | head -1 | cut -d= -f2-)}" + +if [[ -z "$mqtt_password" ]]; then + echo 'erro: MQTT_ACS_PASSWORD está vazio no .env.' >&2 + echo 'Rode ./scripts/dev/bootstrap_env.sh --force para regerar os segredos.' >&2 + exit 1 +fi + +if [[ "$skip_ca" -eq 0 ]]; then + if ! "$repo_root/scripts/dev/sync_dev_ca.sh"; then + if [[ -f "$app_dir/assets/certs/dev_ca.crt" ]]; then + echo 'aviso: a stack parece estar fora do ar; mantendo a CA já copiada.' >&2 + else + echo 'erro: sem a CA do broker o app nem compila (pubspec declara assets/certs/).' >&2 + echo 'Suba a stack com `docker compose up` e rode de novo.' >&2 + exit 1 + fi + fi +fi + +# json_escape: escapa \ e " para o valor caber dentro de uma string JSON. +# Os valores hoje são hex (senha, gerada por bootstrap_env.sh) e host/URL sem +# aspas — não deveria haver o que escapar na prática, mas a função existe para +# não produzir um JSON inválido silenciosamente se isso mudar. +json_escape() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'; } + +# --dart-define-from-file em vez de --dart-define: a senha não pode aparecer +# no argv do flutter, visível em `ps` para outros usuários da mesma máquina. +# mktemp já cria com 0600 e fora da árvore do repositório. SEM `exec` nas +# chamadas de flutter abaixo: `exec` substitui o processo do shell e o trap +# nunca rodaria, deixando o arquivo com a senha esquecido em /tmp — trocaria +# uma exposição temporária por uma persistente, o oposto do que este passo +# quer. +defines_file="$(mktemp)" +trap 'rm -f "$defines_file"' EXIT INT TERM +cat > "$defines_file" <&2 + echo "Suba a stack primeiro (docker compose up) para que o mosquitto-init gere a CA." >&2 + exit 1 +fi + +if ! openssl x509 -in "$source_ca" -noout -checkend 0 >/dev/null 2>&1; then + echo "erro: a CA em $source_ca está expirada." >&2 + echo "Apague infra/docker/mosquitto/runtime/ e suba a stack de novo." >&2 + exit 1 +fi + +mkdir -p "$target_dir" +cp "$source_ca" "$target_ca" + +echo "CA de desenvolvimento copiada para apps/acs/assets/certs/dev_ca.crt" +openssl x509 -in "$target_ca" -noout -subject -dates diff --git a/scripts/qa/e2e.sh b/scripts/qa/e2e.sh new file mode 100755 index 0000000..3601318 --- /dev/null +++ b/scripts/qa/e2e.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# +# Validação ponta a ponta da conexão dos apps com o backend. +# +# ./scripts/qa/e2e.sh # sobe a stack, valida na VM, derruba +# ./scripts/qa/e2e.sh --keep # mantém a stack de pé ao final +# ./scripts/qa/e2e.sh --emulator # inclui os testes de integração no emulador +# +# Sem --emulator, roda as verificações que não precisam de dispositivo +# (tool/live_check.dart dos dois apps), que já exercitam o RPC, o MQTT com TLS e +# a sincronização de visitas usando o código de rede real dos apps. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$repo_root" + +keep_stack=0 +run_emulator=0 +for arg in "$@"; do + case "$arg" in + --keep) keep_stack=1 ;; + --emulator) run_emulator=1 ;; + *) echo "argumento desconhecido: $arg" >&2; exit 2 ;; + esac +done + +cleanup() { + if [[ "$keep_stack" -eq 0 ]]; then + echo + echo '== derrubando a stack ==' + docker compose down + fi +} +trap cleanup EXIT + +# A configuração vem do .env, que não é versionado. Sem ele o docker compose já +# falharia, mas com uma mensagem no meio do build — melhor barrar antes e dizer +# o que fazer. +if [[ ! -f "$repo_root/.env" ]]; then + echo 'erro: .env não existe.' >&2 + echo 'Rode ./scripts/dev/bootstrap_env.sh para gerar a configuração local.' >&2 + exit 1 +fi + +# As senhas do broker são geradas por máquina, então os apps não podem contar +# com o default compilado: o live_check recebe a senha real por argumento. +set -a +# shellcheck disable=SC1091 +source "$repo_root/.env" +set +a + +echo '== subindo a stack ==' +docker compose up --build -d + +echo '== aguardando o backend ficar saudável ==' +for _ in $(seq 1 60); do + status="$(docker inspect -f '{{.State.Health.Status}}' sinalacs-serverpod 2>/dev/null || echo starting)" + [[ "$status" == healthy ]] && break + sleep 2 +done +if [[ "${status:-}" != healthy ]]; then + echo 'erro: o backend não ficou saudável a tempo.' >&2 + docker compose logs --tail 40 serverpod >&2 + exit 1 +fi + +echo '== aplicando o seed de desenvolvimento ==' +# Sem o seed, createRedAlert falha por chave estrangeira em alerts.patientId. +docker compose up database-seed + +echo '== sincronizando a CA do broker para o app do ACS ==' +./scripts/dev/sync_dev_ca.sh + +echo +echo '== paciente: RPC (saúde, login, triagem, alerta, idempotência) ==' +(cd apps/patient && dart pub get >/dev/null && dart run tool/live_check.dart) + +echo +echo '== ACS: ciclo completo (RPC + MQTT/TLS + sincronização de visita) ==' +(cd apps/acs && dart pub get >/dev/null && \ + dart run tool/live_check.dart --mqtt-password "$MQTT_ACS_PASSWORD") + +if [[ "$run_emulator" -eq 1 ]]; then + echo + echo '== testes de integração no dispositivo ==' + # O emulador alcança o host da máquina por 10.0.2.2. + (cd apps/patient && flutter test integration_test \ + --dart-define=SINALACS_HOST=http://10.0.2.2:8080/) + (cd apps/acs && flutter test integration_test \ + --dart-define=SINALACS_HOST=http://10.0.2.2:8080/ \ + --dart-define=SINALACS_MQTT_HOST=10.0.2.2 \ + --dart-define=SINALACS_MQTT_PASSWORD="$MQTT_ACS_PASSWORD") +fi + +echo +echo 'OK — os apps falam com o backend.' diff --git a/spec/PRD_system.md b/spec/PRD_system.md index 9e39819..017d335 100644 --- a/spec/PRD_system.md +++ b/spec/PRD_system.md @@ -381,7 +381,10 @@ erDiagram uuid resource_id timestamp timestamp string ip_hash - string result "SUCCESS | FAILURE | DENIED" + string result "granted | denied_territory, etc." + bigint sequence "posição na cadeia de hash, única" + string previous_hash "entryHash da linha anterior" + string entry_hash "HMAC-SHA256 do conteúdo da linha" } USER ||--o{ PATIENT : is @@ -559,10 +562,11 @@ Cada registro possui um campo `version` (inteiro incremental). No momento da sin **Política ABAC (Atribute-Based Access Control):** -Exemplo ilustrativo da decisão original de stack (estilo Serverpod), sem correspondência com o código real do repositório: +Exemplo ilustrativo: não existe hoje uma camada de política ABAC genérica como esta. A checagem de papel e microárea é feita inline no caso de uso — ver `backend/sinalacs_server/lib/src/application/alerts/red_alert_service.dart` — e o RBAC institucional segue não implementado (ver RNF06 na seção 2.2). A API usada abaixo também é ilustrativa, não é a do ORM do Serverpod. ```dart -// Exemplo ilustrativo — não corresponde ao código real (o backend não usa Serverpod/ORM) +// Exemplo ilustrativo — não há camada ABAC genérica no código real; +// a regra equivalente vive inline em red_alert_service.dart Future canAccessPatient(Session session, String patientId) async { final user = await session.auth.getUser(); switch (user.role) { @@ -592,7 +596,7 @@ Future canAccessPatient(Session session, String patientId) async { | **Comunicação App ↔ Traefik** | TLS 1.3 | Certificado Let's Encrypt (auto-renovável) | Proteção contra MITM | | **Comunicação Traefik ↔ Backend** | TLS 1.3 | Certificado interno (mTLS) | Segurança na rede interna | | **PostgreSQL (SSOT)** | pgcrypto (AES-256) | Chave gerenciada por Vault/HashiCorp | Proteção contra acesso ao banco | -| **Logs de Auditoria** | Assinatura Hash Chain | - | Integridade e não-repúdio | +| **Logs de Auditoria** | Assinatura Hash Chain (HMAC-SHA256) | `AUDIT_CHAIN_SECRET`, próprio, fora do Postgres | Integridade e não-repúdio | #### 4.2.4 Conformidade LGPD (Resumo) diff --git a/spec/assets/contrast_red_fail.png b/spec/assets/contrast_red_fail.png new file mode 100644 index 0000000..aec3d2b Binary files /dev/null and b/spec/assets/contrast_red_fail.png differ diff --git a/spec/assets/contrast_teal_pass.png b/spec/assets/contrast_teal_pass.png new file mode 100644 index 0000000..4c26e73 Binary files /dev/null and b/spec/assets/contrast_teal_pass.png differ diff --git a/spec/assets/contrast_white_pass.png b/spec/assets/contrast_white_pass.png new file mode 100644 index 0000000..cb58c4e Binary files /dev/null and b/spec/assets/contrast_white_pass.png differ diff --git a/spec/lgpd_data_audit.md b/spec/lgpd_data_audit.md new file mode 100644 index 0000000..52e49f7 --- /dev/null +++ b/spec/lgpd_data_audit.md @@ -0,0 +1,264 @@ +# Auditoria de Dados e Conformidade LGPD - SinalACS + +## 1. Inventário de Dados e Classificação de Sensibilidade + +A tabela abaixo consolida o mapeamento exaustivo de dados persistidos pelo backend do SinalACS, contemplando as 11 tabelas de domínio do sistema, os modelos de transporte/persistência intermediária e as tabelas operacionais geradas pelo framework Serverpod. A classificação adota os parâmetros da LGPD (Lei nº 13.709/2018, Art. 5º, I e II), confrontando-os com as invariantes de negócio (INV-01 a INV-05) e os requisitos funcionais de privacidade (LGPD-RF01 a RF21). + +| Tabela / Entidade | Coluna / Atributo | Tipo de Dado | Classificação LGPD | Tratamento Atual | Risco / Avaliação de Conformidade | +| :--- | :--- | :--- | :--- | :--- | :--- | +| **users** | `id` | `uuid` | Pseudonimizado | UUID v4 (`gen_random_uuid()`) | Identificador técnico interno; sem vazamento direto isoladamente. | +| | `cpfHash` | `text` | Identificável / Pseudonimizado | Hash SHA-256 em texto plano | **Crítico:** O CPF possui espaço de busca restrito ($10^9$ combinações válidas); vulnerável a ataques de força bruta e rainbow tables se não utilizar salt/pepper secreto no backend. | +| | `name` | `text` | Identificável (PII Direto) | Texto claro | Necessário para identificação presencial pelo ACS no território; requer controle rigoroso de acesso e segregação estrita por microárea (INV-01). | +| | `birthDate` | `timestamp without time zone` | Identificável (PII Direto) | Timestamp exato | Permite reidentificação por cruzamento com bases cadastrais externas; avaliar truncamento para apenas data (`date`) ou idade calculada. | +| | `role` | `text` | Metadado Técnico | String (`PATIENT`, `ACS`, `ADMIN`) | Controle de perfil e permissões de acesso da aplicação (RBAC). | +| | `microAreaId` | `uuid` | Pseudonimizado / Territorial | Chave estrangeira (`micro_areas.id`) | Essencial para a aplicação da invariante de privacidade INV-01 (delimitação estrita de acesso por microárea). | +| | `createdAt` | `timestamp without time zone` | Metadado Técnico | Timestamp de criação | Registro temporal técnico de auditoria. | +| | `updatedAt` | `timestamp without time zone` | Metadado Técnico | Timestamp de modificação | Rastreabilidade do ciclo de vida cadastral. | +| **patients** | `id` | `uuid` | Pseudonimizado | UUID (vínculo 1:1 com `users.id`) | Identificador do paciente no domínio clínico; sem risco direto de identificação sem junção com a tabela `users`. | +| | `emergencyContact` | `text` | Identificável (PII de Terceiro) | Texto claro (telefone/nome) | **Alto:** Armazena dados de contato de pessoa externa sem gestão documentada de consentimento desse terceiro titular. | +| | `isChronic` | `boolean` | Sensível (Saúde - Art. 5º, II) | Flag booleana | Sinaliza formalmente a existência de condição médica crônica. | +| | `chronicConditions` | `json` | Sensível (Saúde - Art. 5º, II) | JSON estruturado em texto claro | **Crítico:** Lista de comorbidades (ex: diabetes, hipertensão) exposta sem proteção criptográfica; viola a invariante INV-04 e LGPD-RF09 (ausência de `pgcrypto`/AES-256). | +| | `lastLocationHash` | `text` | Pseudonimizado | Geohash (ex: 7 caracteres base32) | Não é irreversível: codifica latitude e longitude com resolução de ~150m; pode revelar o endereço residencial exato do paciente. | +| | `lastTriageAt` | `timestamp without time zone` | Sensível (Metadado Clínico) | Timestamp | Indica data/hora de eventos de triagem clínica recente. | +| **triage_sessions** | `id` | `uuid` | Pseudonimizado | UUID v4 (`gen_random_uuid()`) | Identificador sintético da sessão de triagem. | +| | `patientId` | `uuid` | Pseudonimizado | Chave estrangeira (`patients.id`) | Liga o histórico clínico diretamente ao perfil do paciente no território. | +| | `answers` | `json` | Sensível (Saúde - Art. 5º, II) | JSON contendo lista de `TriageAnswer` | **Crítico:** Contém sintomas declarados e relatos de dor; viola a invariante INV-04 enquanto persistido sem criptografia de repouso. | +| | `resultRisk` | `text` | Sensível (Saúde - Art. 5º, II) | Enum textual (`VERMELHO`, `AMARELO`, etc.) | Classificação determinística calculada no frontend/backend; uso restrito à priorização da fila de atendimento do ACS. | +| | `resultDisplay` | `text` | Sensível (Saúde - Art. 5º, II) | Texto descritivo | Descrição textual com diretivas clínicas associadas aos sintomas avaliados. | +| | `createdAt` | `timestamp without time zone` | Metadado Técnico | Timestamp | Marco temporal para aplicação da política de retenção legal de 5 anos (LGPD-RF07). | +| | `deviceId` | `text` | Identificador de Dispositivo | String de identificador de hardware | Se atrelado a identificadores fixos do sistema (IMEI, MAC, Android ID), caracteriza dado pessoal rastreável; requer pseudonimização com salt efêmero. | +| **triage_answer** *(DTO)* | `questionId`, `value`, `details` | `String` / Estrutura serializada | Sensível (Saúde - Art. 5º, II) | Payload unitário de resposta | Estrutura unitária persistida dentro do campo `triage_sessions.answers`. | +| **visits** | `id` | `uuid` | Pseudonimizado | UUID v4 (`gen_random_uuid()`) | Identificador do registro de visita domiciliar. | +| | `patientId` | `uuid` | Pseudonimizado | Chave estrangeira (`patients.id`) | Identifica o titular do atendimento de saúde. | +| | `acsId` | `uuid` | Pseudonimizado | Chave estrangeira (`acs.id`) | Identifica o profissional responsável pelo atendimento presencial. | +| | `scheduledAt` | `timestamp without time zone` | Metadado Operacional | Timestamp agendado | Data e horário previstos para o atendimento. | +| | `startedAt` | `timestamp without time zone` | Metadado Operacional | Timestamp de início | Registro do início da intervenção no domicílio. | +| | `completedAt` | `timestamp without time zone` | Metadado Operacional | Timestamp de conclusão | Registro da finalização da visita pelo ACS. | +| | `status` | `text` | Metadado Operacional | Enum textual (`SCHEDULED`, `IN_PROGRESS`, etc.) | Estado operacional do ciclo de vida da visita. | +| | `riskLevelBefore` | `text` | Sensível (Saúde - Art. 5º, II) | Enum textual | Avaliação clínica de gravidade anterior à intervenção. | +| | `riskLevelAfter` | `text` | Sensível (Saúde - Art. 5º, II) | Enum textual | Reavaliação clínica executada pelo ACS após o atendimento presencial. | +| | `notes` | `json` | Sensível (Saúde - Art. 5º, II) | JSON em texto claro | **Crítico:** Campo de anotações não estruturadas do ACS; elevado risco de conter diagnósticos, prescrições e relatos sobre terceiros sem anonimização. | +| | `syncStatus` | `text` | Metadado Técnico | Enum textual (`PENDING`, `SYNCED`, `CONFLICT`) | Controle da máquina de estados (FSM) de sincronização offline-first. | +| | `localId` | `uuid` | Pseudonimizado | UUID gerado no SQLite do cliente | Chave de controle de concorrência e idempotência offline. | +| | `syncAt` | `timestamp without time zone` | Metadado Técnico | Timestamp de sincronização | Momento da persistência no banco central. | +| | `version` | `bigint` | Metadado Técnico | Inteiro incremental | Controle de concorrência otimista (OCC) para resolução de conflitos. | +| **alerts** | `id` | `uuid` | Pseudonimizado | UUID v4 (`gen_random_uuid()`) | Identificador do alerta de emergência disparado. | +| | `patientId` | `uuid` | Pseudonimizado | Chave estrangeira (`patients.id`) | Identifica o paciente em risco clínico. | +| | `acsId` | `uuid` | Pseudonimizado | Chave estrangeira (`acs.id`) | Agente que confirmou ou assumiu o atendimento (nulo até o ACK). | +| | `microAreaId` | `uuid` | Pseudonimizado / Territorial | Chave estrangeira (`micro_areas.id`) | Delimita o escopo geográfico da rota de notificação MQTT. | +| | `triggeredAt` | `timestamp without time zone` | Metadado Operacional | Timestamp de acionamento | Base para medição da métrica TMRAV (< 90 segundos). | +| | `receivedAt` | `timestamp without time zone` | Metadado Operacional | Timestamp de entrega | Telemetria de entrega no dispositivo do agente comunitário. | +| | `respondedAt` | `timestamp without time zone` | Metadado Operacional | Timestamp de ação | Telemetria de início de resposta do ACS. | +| | `acknowledgedAt` | `timestamp without time zone` | Metadado Operacional | Timestamp de confirmação | Registro formal de visualização do alerta vermelho (garantia da INV-03). | +| | `riskLevel` | `text` | Sensível (Saúde - Art. 5º, II) | Enum textual (`VERMELHO`, `AMARELO`) | Grau de urgência do acionamento. | +| | `locationHash` | `text` | Pseudonimizado | Geohash (ex: `'6gyf4bf'`) | Delimitação territorial codificada; requer validação de precisão para não fixar o endereço domiciliar exato. | +| | `status` | `text` | Metadado Operacional | Enum textual (`PENDING`, `ACKNOWLEDGED`, etc.) | Estado da máquina de alerta de urgência. | +| | `mqttTopic` | `text` | Metadado Técnico / Territorial | String (`/alerts/{micro_area_id}`) | Canal de mensageria; não deve conter identificadores diretos do paciente no caminho do tópico. | +| | `deviceId` | `text` | Identificador de Dispositivo | String de identificador do hardware | Rastreamento técnico da origem do botão de pânico. | +| | `retryCount` | `bigint` | Metadado Técnico | Inteiro incremental | Controle de resiliência e retentativas na entrega da mensagem. | +| | `version` | `bigint` | Metadado Técnico | Inteiro incremental | Controle de concorrência e idempotência. | +| **alert_deliveries** | `id` | `uuid` | Pseudonimizado | UUID v4 (`gen_random_uuid()`) | Identificador de entrega. | +| | `alertId` | `uuid` | Pseudonimizado | Chave estrangeira (`alerts.id`) | Referência ao alerta entregue. | +| | `acsId` | `uuid` | Pseudonimizado | Chave estrangeira (`acs.id`) | Agente que confirmou a leitura do alerta. | +| | `acknowledgedAt` | `timestamp without time zone` | Metadado Técnico / Legal | Timestamp de confirmação | Prova técnica de recebimento em cumprimento à invariante INV-03. | +| **alert_delivery_record** *(DTO)* | Payload de entrega | `class` Dart | Metadado Operacional / Trânsito | Estrutura de dados em memória | Objeto serializado para publicação MQTT; não deve ser despejado em logs de infraestrutura. | +| **alert_outbox_entry** *(Model/DB)* | `alertId`, `payload`, `status` | Vários | Sensível / Metadado Técnico | Registro transacional da outbox | Garante atomicidade transacional com o broker; o payload retém dados clínicos e geográficos do alerta até o despacho. | +| **alert_idempotency_keys** | `id` | `uuid` | Pseudonimizado | UUID v4 (`gen_random_uuid()`) | Identificador do registro de controle de duplicação. | +| | `key` | `text` | Metadado Técnico | String única de idempotência | Previne múltiplos envios de alertas em rede instável; não deve concatenar PII. | +| | `alertId` | `uuid` | Pseudonimizado | Chave estrangeira (`alerts.id`) | Referência ao alerta vinculado. | +| | `locationHash` | `text` | Pseudonimizado | Geohash | Geohash associado à tentativa original. | +| | `createdAt` | `timestamp without time zone` | Metadado Técnico | Timestamp de expiração | Controle temporal da janela de deduplicação. | +| **consent_logs** | `id` | `uuid` | Pseudonimizado | UUID v4 (`gen_random_uuid()`) | Identificador do log de consentimento (LGPD-RF04). | +| | `userId` | `uuid` | Pseudonimizado | Chave estrangeira (`users.id`) | Identifica o titular do consentimento. | +| | `purpose` | `text` | Metadado Legal | String declarativa de finalidade | Registro da finalidade específica outorgada (LGPD-RF02). | +| | `action` | `text` | Metadado Legal | Enum textual (`GRANT`, `REVOKE`, etc.) | Ação exercida sobre o consentimento pelo titular. | +| | `version` | `text` | Metadado Legal | String de versão semântica | Versão dos termos aceita no momento da ação (LGPD-RT04). | +| | `timestamp` | `timestamp without time zone` | Metadado Legal | Timestamp de registro | Comprovação temporal imutável da manifestação de vontade. | +| | `ipHash` | `text` | Pseudonimizado | Hash SHA-256 do IP | Se gerado para IPv4 sem salt ($2^{32}$ combinações), é reversível por força bruta imediata; requer salt rotativo. | +| | `userAgent` | `text` | Metadado Técnico / Fingerprint | String de cabeçalho User-Agent | Auxilia na caracterização do dispositivo utilizado. | +| | `signature` | `text` | Metadado Legal / Prova Criptográfica | Assinatura digital/hash | Garantia de não repúdio e integridade do consentimento (LGPD-RT05). | +| **audit_logs** | `id` | `uuid` | Pseudonimizado | UUID v4 (`gen_random_uuid()`) | Identificador do registro de auditoria (LGPD-RF11). | +| | `userId` | `uuid` | Pseudonimizado | Chave estrangeira (`users.id`) | Identifica o operador que executou a ação auditada. | +| | `actionType` | `text` | Metadado Técnico | Enum textual (`READ`, `WRITE`, `DELETE`, etc.) | Operação registrada. | +| | `resourceType` | `text` | Metadado Técnico | String de recurso | Entidade ou endpoint manipulado. | +| | `resourceId` | `uuid` | Pseudonimizado | Identificador do recurso | ID do registro visualizado ou modificado. | +| | `timestamp` | `timestamp without time zone` | Metadado Legal / Técnico | Timestamp do evento | Linha temporal imutável de acesso. | +| | `ipHash` | `text` | Pseudonimizado | Hash SHA-256 do IP | Mesma vulnerabilidade de reversão caso não haja salt/HMAC com chave rotativa. | +| | `result` | `text` | Metadado Técnico | Enum textual (`SUCCESS`, `FAILURE`, `DENIED`) | Desfecho da tentativa de acesso. | +| **acs** | `id` | `uuid` | Pseudonimizado | UUID (vínculo com `users.id`) | Identificador de cadastro funcional do agente de saúde. | +| | `enrollmentId` | `text` | Identificável (Funcional) | Matrícula funcional em texto claro | Identificador de agente público; passível de correlação direta com portais municipais de transparência. | +| | `ubsId` | `uuid` | Pseudonimizado / Organizacional | Chave estrangeira (`ubs.id`) | Lotação institucional do profissional de saúde. | +| | `active` | `boolean` | Metadado Operacional | Booleano | Status funcional de permissão de acesso ao sistema. | +| | `lastSyncAt` | `timestamp without time zone` | Metadado Técnico | Timestamp | Última sincronização do app do ACS com o backend. | +| **micro_areas** | `id` | `uuid` | Pseudonimizado / Territorial | UUID v4 (`gen_random_uuid()`) | Identificador do território sanitário de cobertura. | +| | `name` | `text` | Dado Institucional / Organizacional | String | Nome ou código descritivo da microárea na UBS. | +| | `ubsId` | `uuid` | Pseudonimizado / Organizacional | Chave estrangeira (`ubs.id`) | Vinculação com a Unidade Básica de Saúde gestora. | +| | `geoJsonBoundary` | `text` | Dado Territorial / Cartográfico | String GeoJSON | Delimitação dos polígonos geográficos da microárea sanitária. | +| **ubs** | `id` | `uuid` | Pseudonimizado / Organizacional | UUID v4 (`gen_random_uuid()`) | Identificador da unidade de saúde. | +| | `name` | `text` | Dado Institucional Público | String | Razão social ou denominação do posto de atendimento. | +| | `address` | `text` | Dado Institucional Público | String de endereço | Endereço físico do equipamento de saúde pública. | +| | `city` | `text` | Dado Territorial Público | String de município | Município de lotação da UBS. | +| | `state` | `text` | Dado Territorial Público | String UF | Estado da federação de lotação da UBS. | +| **serverpod_query_log** | `query` | `text` | **Risco Crítico de Fuga Indireta** | Texto SQL completo de queries lentas/falhas | **Crítico:** Se comandos `INSERT`/`UPDATE` com `name`, `emergencyContact`, `chronicConditions` ou `notes` falharem ou forem lentos, o comando SQL completo com dados sensíveis em texto claro será gravado nesta tabela técnica. | +| | Demais colunas | Vários | Metadados de Sistema | Timestamps, durações e IDs numéricos | Métricas de telemetria e depuração de queries do banco de dados. | +| **serverpod_message_log** | `error` / `stackTrace` | `text` | Risco Moderado de Vazamento | Dump de exceções não tratadas | Risco de exposição de payloads RPC contendo dados clínicos sensíveis ou identificadores em stacktraces não sanitizados. | +| | Demais colunas | Vários | Metadados Operacionais | Nomes de métodos RPC, durações e flags | Rastreabilidade de chamadas da API interna. | +| **serverpod_session_log** | `authenticatedUserId` / `userId` | `bigint` / `text` | Pseudonimizado / Técnico | Identificadores de sessão Serverpod | Rastreamento técnico da sessão HTTP/RPC. | +| | Demais colunas | Vários | Metadados de Diagnóstico | Timestamps, contadores de queries e status de erro | Métricas técnicas de desempenho do servidor. | +| **serverpod_cloud_storage*** | Todas as colunas | `bytea`, `text`, etc. | Infraestrutura Interna | Tabelas de blobs e uploads do Serverpod | Devem ser mantidas com permissões estritas para evitar persistência indevida de dados não estruturados de pacientes. | +| **serverpod_health_*** / **future_call** / **migrations** | Todas as colunas | Vários | Metadados de Infraestrutura | Métricas de CPU/memória, controle de jobs e migrações | Neutro sob a ótica de privacidade de titulares de dados pessoais. | + +## 2. Avaliação de Mecanismos Criptográficos e Pseudonimização + +A análise técnica do schema e da base de código do backend (`backend/sinalacs_server`) aponta discrepâncias críticas entre os requisitos de segurança formalizados na especificação do sistema (`spec/PRD_system.md` e `spec/lgpd_design.md`) e a implementação concreta persistida nas migrações do PostgreSQL. Em diversos pontos, mecanismos de mascaramento tratam dados identificáveis e de saúde sob pseudonimização frágil ou texto claro. + +--- + +### 2.1. Análise de Entropia e Fragilidades de Hashing + +A inspeção estática demonstrou que o backend não calcula hashes criptográficos; ele se limita a persistir strings pré-calculadas fornecidas pelos clientes via RPC ou scripts de seed. Isso transfere o risco para os nós de borda e expõe campos críticos a ataques de força bruta e tabelas de busca pré-computadas (*rainbow tables*). + +#### Fragilidade Estrutural em `users.cpfHash` +* **Mapeamento:** Coluna `cpfHash text NOT NULL` indexada via B-tree na tabela `users`. +* **Implementação de Referência:** O helper de seed no PRD documenta o uso de `sha256('123.456.789-00')`. +* **Vulnerabilidade de Entropia:** O CPF é composto por 11 dígitos decimais, dos quais os 2 últimos são dígitos verificadores calculados diretamente sobre os 9 primeiros. Consequentemente, existem apenas $10^9$ combinações possíveis no espaço amostral do documento. +* **Risco de Segurança:** Uma GPU moderna executa bilhões de operações SHA-256 por segundo. Sem a utilização de *salt* individual ou chave secreta global (*pepper*), todo o espaço de CPFs pode ser pré-computado em *rainbow tables* em questão de segundos, anulando o efeito da pseudonimização e convertendo o hash em dado nominal reversível. + +#### Fragilidade em `consent_logs.ipHash` e `audit_logs.ipHash` +* **Mapeamento:** Colunas `ipHash text NOT NULL` registradas para atendimento aos requisitos LGPD-RF04 e LGPD-RF11. +* **Vulnerabilidade de Entropia:** O espaço de endereçamento público do IPv4 é limitado a $2^{32} \approx 4,29 \times 10^9$ combinações possíveis. +* **Risco de Segurança:** A ausência de segredo computacional torna a reversão de IPs por força bruta quase instantânea, expondo o histórico de navegação e a localização geográfica de rede do titular. + +--- + +### 2.2. Risco de Reidentificação Espacial via Geohash (`locationHash`) + +Os campos `patients.lastLocationHash`, `alerts.locationHash` e `alert_idempotency_keys.locationHash` são descritos formalmente como "hash de localização", porém a análise dos testes de unidade (`red_alert_service_test.dart`) revela que o sistema utiliza a codificação alfanumérica **Geohash base32** (ex: `'6gyf4bf'`). + +* **Natureza Algorítmica:** Geohash não é uma função criptográfica unidirecional (irreversível); trata-se de uma partição hierárquica do espaço espacial que decodifica diretamente para intervalos exatos de latitude e longitude. +* **Resolução Geométrica:** Uma cadeia Geohash com precisão de 7 caracteres especifica uma área aproximada de $\approx 153\text{ m} \times 153\text{ m}$. Em áreas urbanas de alta densidade, esse raio isola um quarteirão; em zonas rurais, aponta com frequência para uma única propriedade rural ou domicílio isolado. +* **Consequência LGPD:** A precisão atual de 7 caracteres permite identificar indiretamente a residência do titular quando combinada com a delimitação de microárea sanitária (`microAreaId`), contrariando a premissa de desidentificação do dado em repouso. + +--- + +### 2.3. Armazenamento em Texto Claro de Dados Sensíveis de Saúde (Violação INV-04) + +A invariante de negócio **INV-04** estabelece categoricamente: *"Dados de saúde sensíveis nunca podem ser persistidos em texto plano. Criptografia AES-256 em sqflite e PostgreSQL"*. Adicionalmente, os requisitos **LGPD-RF09** e **LGPD-RT02** determinam o uso de criptografia em repouso para mitigar o risco de vazamento em caso de comprometimento do banco. + +A inspeção do schema físico (`definition.sql` e modelos `.spy.yaml`) revelou que três colunas contêm dados sensíveis (Art. 5º, II da LGPD) persistidos como tipos `json` ou `text` nativos, sem qualquer camada de cifra criptográfica: + +1. **`patients.chronicConditions` (`json`):** Persiste diretamente diagnósticos clínicos estruturados (ex: `['diabetes', 'hipertensao']`). +2. **`triage_sessions.answers` (`json`):** Armazena a listagem de respostas de triagem com a descrição de sintomas, localização corporal e intensidade de dores. +3. **`visits.notes` (`json`):** Armazena anotações livres e não estruturadas realizadas pelo ACS durante o atendimento domiciliar, com alto risco de retenção de dados clínicos circunstanciais e menções a terceiros. + +A exposição desses campos em repouso gera vulnerabilidade crítica: qualquer leitura indevida decorrente de dump de banco, backup comprometido ou injeção de logs expõe de imediato o histórico clínico completo do paciente. + +--- + +### 2.4. Identificadores de Dispositivo (`deviceId`) + +As tabelas `alerts` e `triage_sessions` retêm a coluna `deviceId text NOT NULL`. +* Caso os aplicativos enviem identificadores imutáveis do sistema operacional (como `android_id`, IMEI ou endereço MAC), esse valor atua como PII indireto perene, permitindo correlacionar o histórico de atendimentos a um aparelho físico mesmo após a troca de usuário. +* Para conformidade com os princípios da necessidade e minimização (Art. 6º, I e III da LGPD), o identificador de dispositivo deve ser restrito a uma chave de instalação pseudoaleatória gerada localmente no app móvel ou mascarada antes da persistência. + +--- + +### 2.5. Dados Cadastrais e Credenciais de Autenticação (`users` e `patients`) + +* **`users.name`:** Mantido em texto claro no banco por necessidade estrita da operação assistencial domiciliar do ACS (Art. 6º, I e Art. 7º, V da LGPD). Requer controle de acesso rígido por microárea (INV-01) para que outros agentes não visualizem a listagem nominal. +* **`users.birthDate` como Fator de Autenticação (RF01):** Conforme definido no requisito RF01 do PRD, a data de nascimento atua conjuntamente com o CPF como credencial no fluxo de *Login Passwordless*. + * **Minimização de Tipo:** Como a autenticação exige a data exata, o truncamento para ano/idade é inviável sem quebrar o login. No entanto, o tipo de dado atual no PostgreSQL (`timestamp without time zone`) deve ser alterado para o tipo `date`, descartando horas, minutos e segundos desnecessários. + * **Risco de Correlação:** Armazenar a data de nascimento em texto claro ao lado de um `cpfHash` frágil amplia exponencialmente o risco de reidentificação por cruzamento com bases públicas vazadas. A proteção de `users.cpfHash` via HMAC-SHA-256 com segredo (*pepper*) do backend torna-se mandatória para impedir que a credencial de login do paciente seja quebrada em caso de vazamento do banco. +* **`patients.emergencyContact`:** Armazena dados de contato de terceiros (telefone/nome) em texto claro sem termo de consentimento específico. Deve permanecer sob acesso restrito (RBAC), sendo descriptografado ou revelado ao ACS exclusivamente durante o tratamento de alertas de emergência confirmados (alerta vermelho). + +--- + +### 2.6. Recomendações Técnicas de Remediação Criptográfica + +| Campo Auditado | Vulnerabilidade Identificada | Remediação Técnica Recomendada | +| :--- | :--- | :--- | +| `users.cpfHash` | Espaço amostral $10^9$; reversível por força bruta em segundos. | Migrar para **HMAC-SHA-256** utilizando segredo corporativo (*pepper*) injetado via variável de ambiente restrita ao backend (`CPF_HASH_PEPPER`). O cálculo deve ser feito exclusivamente pelo servidor. | +| `users.birthDate` | Tipo com precisão excessiva de horário (`timestamp`) em credencial sensível. | Alterar a coluna para o tipo SQL `date`, preservando o valor exato para o login passwordless (RF01) e eliminando horas/minutos/segundos. | +| `patients.emergencyContact` | PII de terceiro exposto em texto claro sem consentimento formal direto. | Criptografar a coluna com chave simétrica da aplicação ou proteger o acesso via endpoint dedicado liberado apenas durante o ciclo de vida de um alerta ativo. | +| `consent_logs.ipHash` e `audit_logs.ipHash` | Espaço amostral de IPv4 ($2^{32}$) facilmente mapeável via rainbow table. | Utilizar **HMAC-SHA-256 com rotação periódica de salt** (chave de rotação diária/semanal), preservando a correlação de incidentes daquela janela sem permitir persistência do rastro de rede do titular. | +| `patients.chronicConditions` | Comorbidades e diagnósticos de saúde persistidos em texto plano (violação INV-04). | Criptografia em repouso no nível de aplicação (AES-256-GCM) antes do insert, ou adoção da extensão `pgcrypto` (`pgp_sym_encrypt`) no PostgreSQL, conforme previsto em LGPD-RT02. | +| `triage_sessions.answers` | Respostas clínicas e relatos de sintomas persistidos em JSON aberto. | Serializar e cifrar o payload de respostas com chave simétrica derivada ou chave mestra mantida fora do banco de dados (Envelope Encryption). | +| `visits.notes` | Texto livre do ACS sem higienização, contendo dados clínicos sensíveis. | Criptografia simétrica compulsória em repouso e implementação de máscara ou sanitização preventiva na sincronização. | +| `alerts.locationHash` | Geohash de 7 caracteres especifica área de ~150 metros, viabilizando reidentificação domiciliar. | Truncar o Geohash para 5 caracteres ($\approx 4,9\text{ km} \times 4,9\text{ km}$) ou 6 caracteres ($\approx 1,2\text{ km} \times 0,6\text{ km}$) para roteamento de microárea, isolando as coordenadas exatas apenas no canal de despacho imediato. | +| `alerts.deviceId` e `triage_sessions.deviceId` | Rastreamento persistente de hardware do titular. | Substituir por identificador de instalação efêmero (UUID gerado no onboarding do app e descartado na limpeza de dados). | + +## 3. Matriz de Pontos de Vazamento e Riscos Identificados + +A auditoria estática do fluxo de dados, pontos de entrada RPC, rotinas em segundo plano e infraestrutura de deploy do `sinalacs_server` mapeou os vetores de exposição involuntária de dados pessoais, registros clínicos e credenciais. + +--- + +### 3.1. Matriz de Riscos de Vazamento + +| ID | Componente / Arquivo | Severidade | Descrição do Risco | Mitigação Recomendada | +| :--- | :--- | :--- | :--- | :--- | +| **VAZ-01** | `lib/src/infrastructure/mqtt/mqtt_alert_dispatcher.dart` (linhas 43, 103, 132), `lib/src/application/alerts/alert_outbox_dispatcher.dart` (linha 69) e `lib/server.dart` (linha 96) | **Média** | Despejo de exceções brutas via `stderr.writeln` em falhas de mensageria MQTT e varredura de outbox. Caso o `$error` interceptado contenha instâncias serializadas de `AlertOutboxEntry` ou `AlertDeliveryRecord`, dados sensíveis como `locationHash` e identificadores clínicos são despejados nos logs do console da hospedagem. | Sanitizar a mensagem antes de gravar em `stderr`, registrando apenas o tipo do erro e identificadores sintéticos (ex.: `alertId`), sem imprimir o objeto de domínio bruto ou payloads MQTT. | +| **VAZ-02** | `serverpod_query_log` (Tabela interna do Serverpod) | **Alta** | Persistência indireta de PII e dados sensíveis de saúde em texto claro. Caso transações com o ORM (como em `AlertsEndpoint.createRedAlert` ou sincronização de visitas) falhem ou sofram lentidão, o framework grava a instrução SQL completa na coluna `query`, expondo valores literais de inserções em `users`, `patients`, `triage_sessions` e `visits`. | Configurar `logSettings` em `serverpod_runtime_settings` para desativar o registro textual de queries SQL completas em ambientes com dados reais, assegurando o uso exclusivo de prepared statements parametrizados. | +| **VAZ-03** | `serverpod_message_log` e `serverpod_log` | **Média** | Vazamento de argumentos RPC e sessões em stacktraces. Exceções não tratadas durante chamadas de endpoint podem despejar argumentos serializados de requisições nas colunas `error` e `stackTrace` das tabelas de telemetria do Serverpod. | Implementar filtro global de exceções para expurgar cargas úteis e PII antes da gravação de stacktraces no banco de dados. | +| **VAZ-04** | Broker MQTT Gerenciado (HiveMQ Cloud / Piloto Free-Tier) | **Alta** | Ausência de ACLs dinâmicas por microárea e chaveamento compartilhado (`backend/DEPLOY.md`). O piloto adota credenciais globais estáticas (`MQTT_USERNAME`/`MQTT_PASSWORD`) sem segregação estrita por tópico, permitindo que qualquer nó autenticado assine `/alerts/#` e intercepte alertas de terceiros. | O ambiente free-tier deve permanecer restrito a dados sintéticos. Para produção, adotar broker corporativo (ex.: Mosquitto/EMQX) com autenticação mTLS e arquivos de ACL dinâmicos vinculados à microárea (garantia da INV-01). | +| **VAZ-05** | Topologia Neon / Render (Armazenamento e Computação em Nuvem Pública) | **Média** | Custódia e processamento de dados por operadores terceirizados sem salvaguardas contratuais formais (DPA - *Data Processing Agreement*). Viola o princípio da responsabilização e LGPD-RF17 caso dados reais trafeguem antes da celebração dos instrumentos legais. | Manter a infraestrutura gratuita estritamente restrita a seeds e testes sintéticos. Em ambiente assistencial, formalizar instâncias isoladas (VPC/on-premise institucional) ou contratos corporativos com DPA ativo. | +| **VAZ-06** | `config/passwords.yaml` (Higiene de Repositório) | **Baixa** | Risco de vazamento de segredos mestres do banco de dados em repositórios remotos. A checagem via `git check-ignore` confirmou que o arquivo está devidamente ignorado (.gitignore:15), mas exige monitoramento contínuo contra desvios de branch. | Manter bloqueio no pipeline de CI com ferramentas de *Secret Scanning* (ex: Gitleaks/TruffleHog) para impedir commits acidentais de senhas locais. | + +--- + +### 3.2. Detalhamento dos Vetores de Vazamento + +#### 3.2.1. Auditoria de Boot e Emissões em Console +A varredura estática por `print(` confirmou que o projeto não possui chamadas residuais de depuração direta em stdout. O vazamento originalmente citado na issue (`print('Database URL: ${config.databaseUrl}')`) pertencia à implementação legada `dart:io` e já foi sanado na migração para Serverpod. + +Contudo, a inspeção de fluxos de erro revelou 5 ocorrências de `stderr.writeln` associadas à resiliência do MQTT e ao outbox (`mqtt_alert_dispatcher.dart`, `alert_outbox_dispatcher.dart` e `server.dart`). Em plataformas como Render ou containers Docker, mensagens descarregadas em `stderr` são arquivadas sem tratamento em aggregators de log, criando o risco de expor atributos internos de alertas durante falhas transitórias de conexão. + +#### 3.2.2. Efeito Colateral do Logging Técnico do Serverpod +O Serverpod gera nativamente tabelas de diagnóstico operacional (`serverpod_query_log`, `serverpod_session_log`, `serverpod_message_log`, `serverpod_log`). +* Embora métodos como `AlertsEndpoint.createRedAlert` realizem validações seguras e encapsulem transações via `session.db.transaction`, uma falha de banco aciona a persistência do comando SQL textual em `serverpod_query_log.query`. +* Se dados sensíveis de saúde (`chronicConditions`, `answers`, `notes`) estiverem em texto claro no momento da inserção, a tabela de log passa a atuar como vetor secundário de retenção desprotegida de PII e dados clínicos. + +#### 3.2.3. Superfície de Mensageria no Piloto Gratuito (HiveMQ Cloud) +Conforme detalhado no documento `backend/DEPLOY.md`, o piloto gratuito utiliza o HiveMQ Cloud em plano compartilhado, onde não há segregação de permissões de leitura por tópico baseada em território sanitário. Clientes que utilizarem as credenciais do piloto podem monitorar livremente as publicações da raiz `/alerts/*`, permitindo que um agente escute alertas emitidos em microáreas para as quais não possui atribuição assistencial, quebrando a garantia da invariante INV-01. + +## 4. Retenção, Ciclo de Vida e Ambientes Não Produtivos (LGPD-RF07) + +A gestão do ciclo de vida dos dados é um pilar da LGPD (Art. 15 e 16). O sistema precisa garantir que os dados pessoais não sejam mantidos indefinidamente sem justificativa e que ambientes de engenharia não sejam contaminados com dados reais de titulares. + +### 4.1. Avaliação de Retenção e Exclusão (Ausência de Expurgo) +* **Requisito Legal:** O requisito **LGPD-RF07** estabelece que dados de saúde (alertas, triagens, visitas) devem ser retidos pelo prazo mínimo legal (5 anos) e, após esse período, anonimizados ou eliminados de forma segura por meio de um mecanismo automático de expurgo[cite: 4]. +* **Cenário Atual:** A inspeção nas tabelas `alerts`, `visits` e `triage_sessions` (conforme as migrações geradas pelo Serverpod) confirma a ausência de qualquer *cron job*, *trigger* de banco de dados ou rotina em segundo plano (como *FutureCalls* do Serverpod) programada para limpar registros antigos[cite: 6]. +* **Avaliação:** **Inconforme**. O acúmulo contínuo sem rotina de anonimização ou exclusão viola o princípio da necessidade da LGPD, aumentando a superfície de risco em caso de vazamento futuro. + +### 4.2. Dados em Ambientes Não Produtivos e CI/CD +* **Diretriz:** A invariante de segurança do projeto proíbe estritamente o uso de dados reais de pacientes em testes, logs, capturas de tela ou configurações de desenvolvimento[cite: 5]. +* **Cenário Atual:** A análise do seed em `backend/sinalacs_server/lib/src/infrastructure/database/seeds/development.sql` confirmou a aderência total à regra. Os dados são 100% fictícios: + * Identificadores nominais declarativos (`'Paciente de desenvolvimento'`). + * Hashes sintéticos (`'development-patient'`, `'development-acs'`) no lugar de CPFs formatados. + * O `auth.developmentLogin` utiliza chaves e UUIDs fixos que não correspondem a nenhuma base real, impedindo colisão com titulares[cite: 5]. +* **Avaliação:** **Conforme**. A higiene de dados sintéticos mitiga o risco de exposição de PII em ambientes de Integração Contínua (CI) e testes locais. + +### 4.3. Restrições do Piloto Free-Tier +* **Cenário Atual:** O documento de deploy descreve a hospedagem do protótipo em serviços gratuitos gerenciados por terceiros (Render, Neon, HiveMQ Cloud)[cite: 1]. +* **Avaliação:** Embora arquiteturalmente válido para validação (devido ao suporte a contêineres e hibernação transparente), **é inaceitável o uso de dados reais neste ambiente**[cite: 1]. A falta de Data Processing Agreements (DPAs) firmados com esses fornecedores e a ausência de controles rígidos de retenção tornam o piloto free-tier uma zona de alto risco regulatório se exposta à operação assistencial verdadeira. + +--- + +## 5. Plano de Ação Recomendado + +Com base nas vulnerabilidades e desvios de conformidade mapeados neste relatório, recomenda-se a seguinte ordem de priorização para adequação à LGPD e às invariantes do SinalACS: + +### Prioridade Alta (Curto Prazo - Prevenção de Vazamento Imediato) +1. **Sanitizar Logs de Exceção:** Ajustar os blocos `try/catch` da varredura de outbox e do dispatcher MQTT no backend para não imprimir as entidades de domínio brutas no `stderr`. +2. **Fortalecer Hashes Autenticadores:** Migrar a geração de `users.cpfHash` (usado no login) para **HMAC-SHA-256** utilizando um segredo de servidor (*pepper*). O cálculo deve ocorrer exclusivamente no backend. +3. **Ajustar Tipagem da Data de Nascimento:** Converter a coluna `users.birthDate` para `date` no schema, eliminando a precisão desnecessária de horas/minutos exigida pelo `timestamp` atual. +4. **Isolar o Ambiente Free-Tier:** Reforçar bloqueios ou avisos na UI do piloto garantindo que ele seja alimentado estritamente pelo seed sintético de desenvolvimento. + +### Prioridade Média (Médio Prazo - Criptografia em Repouso e Telemetria) +5. **Criptografar Campos de Saúde (Violação INV-04):** Implementar criptografia de coluna (via `pgcrypto` ou criptografia simétrica na aplicação) para proteger `patients.chronicConditions`, `triage_sessions.answers` e `visits.notes` contra leituras indevidas no banco. +6. **Mascarar Logs do Serverpod:** Desativar ou configurar máscaras para o `serverpod_query_log` em produção, impedindo que transações SQL lentas deixem rastros em texto claro de diagnósticos ou dados de anamnese. +7. **Anonimizar Origens de Rede:** Implementar salt rotativo no cálculo de `ipHash` para os registros de `consent_logs` e `audit_logs`. + +### Prioridade Baixa (Longo Prazo - Ciclo de Vida e Infraestrutura) +8. **Rotina de Expurgo (LGPD-RF07):** Desenvolver um Serverpod *FutureCall* ou rotina periódica no banco para efetuar o *soft-delete* com anonimização de registros de visitas e alertas com mais de 5 anos[cite: 4]. +9. **Reduzir Precisão do Geohash:** Avaliar com a área de produto o truncamento de `locationHash` de 7 para 5 ou 6 caracteres em áreas rurais, impedindo a reidentificação determinística do domicílio do paciente na base de dados. \ No newline at end of file diff --git a/spec/lgpd_design.md b/spec/lgpd_design.md index 81aa7a8..60523b5 100644 --- a/spec/lgpd_design.md +++ b/spec/lgpd_design.md @@ -431,6 +431,17 @@ A estratégia de conformidade adota os princípios de **Privacy by Design** e ** | **Implementação** | TLS para todas as comunicações (Flutter ↔ backend), AES-256 para dados locais no `sqflite`, criptografia de campos sensíveis (CPF, condições crônicas) no banco central PostgreSQL. | | **Riscos Mitigados** | Interceptação de dados (MITM), violação de dados por acesso físico ao dispositivo, vazamento em caso de comprometimento do banco de dados. | +#### Decisão de custódia da chave (app do ACS) + +| Propriedade | Descrição | +|-------------|-----------| +| **Decisão** | A chave do SQLCipher é aleatória (256 bits) e fica no Android Keystore / iOS Keychain, via `flutter_secure_storage`. Não é derivada de PIN nem de biometria. | +| **Contexto** | `spec/PRD_system.md` (4.2.3) prescreve "chave derivada do PIN/Biometria (PBKDF2)"; `spec/test_plan.md` (item 1 da matriz de risco) fala em "chave gerada pelo TEE do hardware". Adotamos a segunda leitura. | +| **Motivo** | Não existe nenhum fluxo de PIN nos apps — a autenticação é `auth.developmentLogin`, sem credencial. Derivar de PIN exigiria projetar definição, desbloqueio, política de tentativas e recuperação; e um PIN esquecido significaria perder visitas ainda não sincronizadas. O keystore protege contra o risco que esta seção nomeia (acesso físico ao dispositivo) sem inventar produto. | +| **Reversibilidade** | A interface `DatabaseKeyStore` aceita um segundo fator depois, envelopando a chave, **sem migrar dados**. O caminho do PRD segue aberto. | +| **Limite conhecido** | Num aparelho comprometido (root) com o usuário autenticado, a chave é alcançável. Proteger contra isso exigiria o fator de posse do PIN. | +| **Verificação** | `apps/acs/integration_test/encrypted_storage_test.dart` lê o arquivo do banco e afirma que ele não contém o conteúdo em texto plano. Roda em dispositivo — no CI (Linux) o caminho é o FFI, que não criptografa, e por isso a abertura fora de Android/iOS lança por padrão. | + ### 5.2 Controle de Acesso por Perfil (RBAC) | Propriedade | Descrição | @@ -471,6 +482,17 @@ A estratégia de conformidade adota os princípios de **Privacy by Design** e ** | **Implementação** | Tabela com política de retenção por categoria de dado: alertas (2 anos), visitas (5 anos conforme legislação), logs de acesso (1 ano), dados de consentimento (indeterminado). Processo automatizado de expurgo com anonimização. | | **Riscos Mitigados** | Acumulação excessiva de dados, violação do princípio da necessidade, armazenamento desnecessário de dados sensíveis. | +#### Retenção no aparelho do ACS (implementada) + +| Propriedade | Descrição | +|-------------|-----------| +| **Decisão** | O banco local guarda **apenas** visitas ainda não sincronizadas. `SqlCipherVisitStore.save()` reescreve a tabela inteira a partir da lista de pendentes, então a visita confirmada pelo servidor deixa o dispositivo na gravação seguinte. | +| **Estado** | Passou a valer de fato quando o `BackendVisitSynchronizer` foi ligado em produção. Antes, o app montava a fila sem sincronizador: `sync()` devolvia erro, nada era confirmado e nada era apagado — a retenção estava implementada e testada, mas nunca disparava. | +| **O que é gravado** | `patient_id` (UUID), risco, status, desfecho, data e versão. **Não** é gravado nome, rótulo legível nem endereço. As observações de campo digitadas na tela ainda não são persistidas nem enviadas. | +| **Por que o identificador, e não o rótulo** | A tela antes gravava `'Paciente ' + 8 dos 32 dígitos hex` e descartava o UUID. Era pior nos dois sentidos: texto legível sobre a pessoa no disco, e um identificador irrecuperável — `visits.sync` só aceita UUID, então a visita nunca poderia sair do aparelho e a retenção nunca a alcançaria. Guardar o identificador e montar o rótulo na tela é o desenho de minimização correto (LGPD-RF01). | +| **Consequência de produto** | Sem alerta vinculado não há paciente, logo a tela de visita não grava. Um seletor de pacientes da microárea exigiria um endpoint de listagem que não existe. | +| **Verificação** | `apps/acs/integration_test/encrypted_storage_test.dart` afirma que nem o conteúdo da visita nem o `patient_id` aparecem legíveis no arquivo do banco; `apps/acs/integration_test/red_alert_cycle_test.dart` ("a visita confirmada SAI do disco criptografado") prova a retenção contra o servidor real, em dispositivo. | + ### 5.7 Anonimização/Pseudonimização | Propriedade | Descrição | @@ -567,8 +589,8 @@ A estratégia de conformidade adota os princípios de **Privacy by Design** e ** | Propriedade | Descrição | |-------------|-----------| | **Descrição** | Todos os acessos e operações devem ser registrados em logs estruturados (JSON), imutáveis, e auditáveis. | -| **Implementação** | Tabela `audit_logs` no PostgreSQL; assinatura criptográfica dos logs (hash chain); armazenamento em tabela imutável (append-only). | -| **Critério de Aceite** | ✓ Todos os endpoints geram logs
✓ Logs são imutáveis (append-only)
✓ Logs incluem data, usuário, ação, IP (anonimizado) | +| **Implementação** | Tabela `audit_logs` no PostgreSQL; assinatura criptográfica dos logs (hash chain); armazenamento em tabela imutável (append-only). A tabela existia desde a migração-base sem escritor nenhum; os dois primeiros — `patients.listMicroArea` e a recusa por território em `visits.sync` (`AuditTrail`, `backend/sinalacs_server/lib/src/application/audit/`) — foram ligados, sempre com `ipHash` (nunca IP em claro) e best-effort (uma falha na trilha não derruba a operação clínica). **A assinatura em hash chain está implementada** (`AuditChain`, no mesmo diretório): cada linha carrega `sequence`, `previousHash` e `entryHash` — um HMAC-SHA256 sobre o conteúdo da linha, com um segredo próprio (`AUDIT_CHAIN_SECRET`) que nunca deriva do `JWT_SECRET`, para que rotacionar um não afete o outro. A escrita é serializada por `pg_advisory_xact_lock` dentro da mesma transação do apêndice, e um índice único em `sequence` é o segundo cinto contra bifurcação por concorrência. `AuditChainVerifier` (mesmo pacote) e `bin/audit_chain_check.dart` verificam a cadeia inteira sob demanda, detectando edição, remoção ou reordenação de qualquer linha — inclusive por quem tem acesso de escrita direto ao Postgres, já que o segredo fica fora do banco. **O que ainda falta**: o append-only em si não é imposto pelo banco (nenhum trigger/`REVOKE` bloqueia `UPDATE`/`DELETE` em `audit_logs` — a cadeia só *detecta* a violação, não a impede), e os demais endpoints que tocam dado sensível (alertas, ack, triagem) ainda não escrevem na trilha. | +| **Critério de Aceite** | ✓ Todos os endpoints geram logs (parcial: só `patients.listMicroArea` e a recusa territorial de `visits.sync`)
✓ Logs têm assinatura em hash chain verificável (`AuditChainVerifier`)
✗ Logs são imutáveis por construção do banco (append-only ainda é só convenção, sem trigger/`REVOKE`)
✓ Logs incluem data, usuário, ação, IP (anonimizado) | ### LGPD-RT04 - Consentimento Versionado diff --git a/spec/security_assessment.md b/spec/security_assessment.md new file mode 100644 index 0000000..3bd13b5 --- /dev/null +++ b/spec/security_assessment.md @@ -0,0 +1,332 @@ +# Análise de Cibersegurança — SinalACS + +> Referência: NIST Cybersecurity Framework (CSF) 2.0 como estrutura principal, +> complementado por controles do NIST SP 800-53 Rev. 5 onde fez sentido +> detalhar. Ver [issue #1](../../../issues/1). + +**Relatório Baseado no código disponível no dia 15/09/2026** + +## 1. Contexto e metodologia + +Esta análise é uma revisão estática (leitura de código, configuração e +documentação — sem testes de penetração ativos) do estado do repositório na +revisão avaliada. Cobre: + +- `backend/sinalacs_server` (Serverpod/Dart): endpoints, autenticação, + autorização por microárea, MQTT, banco, auditoria, configuração. +- `apps/acs`, `apps/patient`, `apps/admin` (Flutter): apenas superficialmente, + focando em como consomem a autenticação do backend. +- `infra/docker`, `backend/DEPLOY.md`: topologia do piloto free-tier. +- `spec/lgpd_design.md`, `spec/PRD_system.md`, `README.md`, + `.github/SECURITY.md`: requisitos já assumidos pelo projeto, para checar + aderência técnica. + +**Nota importante sobre desatualização da issue**: a issue #1 descreve um +servidor `dart:io` cru (`backend/bin/server.dart`, `postgres_alert_store.dart`) +que já não existe nesta revisão — o backend migrou para o framework +**Serverpod**. Vários pontos levantados na issue já foram endereçados nessa +migração (autenticação JWT HMAC com verificação por endpoint, autorização por +microárea reforçada no banco, MQTT com ACK e outbox transacional, segredos com +fail-closed fora de `development`). Isso é registrado explicitamente abaixo em +cada achado correspondente, para não reabrir como novo o que já foi corrigido +— e para focar o esforço no que **de fato** continua em aberto. + +Severidade: **Crítica / Alta / Média / Baixa / Informativa**, considerando que +o projeto é um piloto acadêmico sem dados reais de pacientes (conforme +`README.md` e `.github/SECURITY.md`), não um sistema em produção com dados +sensíveis reais. + +## 2. Sumário executivo + +| ID | Achado | Função CSF | Severidade | Status | +|----|--------|-----------|------------|--------| +| F1 | Dev-login como único mecanismo de autenticação | PROTECT (PR.AA) | Alta | Conhecido/documentado, sem mitigação | +| F2 | ACL do broker MQTT no piloto não segrega por microárea | PROTECT (PR.DS/PR.AA) | Alta | Regressão dev→piloto, não documentada como risco | +| F3 | TLS do MQTT não é obrigatório por ambiente | PROTECT (PR.DS) | Média | Gap de "fail closed" | +| F4 | Autorização por endpoint é manual, não há enforcement central | PROTECT (PR.AA) | Média/Alta | Débito de arquitetura | +| F5 | Ausência de MFA para ACS | PROTECT (PR.AA) | Média | Gap frente a LGPD-RF11 | +| F6 | Sem rate limiting / anti-automação no `developmentLogin` | PROTECT (PR.AA) | Média | Gap | +| F7 | Gestão de segredos correta em código, mas sem cofre/rotação | PROTECT (PR.DS) | Baixa/Média | Parcialmente mitigado | +| F8 | Sem correlação/alerta de eventos de segurança (SIEM) | DETECT (DE.CM) | Média | Gap | +| F9 | Sem plano de resposta a incidentes / SLA ANPD | RESPOND (RS.MA/RS.CO) | Alta (para LGPD) | Gap | +| F10 | Sem scanning automatizado de dependências | IDENTIFY (ID.RA) | Média | Gap | +| F11 | Drift entre `README.md` e o estado real de segurança | GOVERN (GV.OC) | Informativa | Documentação desatualizada | +| F12 | Sem plano de recuperação/backup testado para o piloto | RECOVER (RC.RP) | Baixa | Gap (aceitável em piloto) | + +## 3. Achados detalhados por função do NIST CSF 2.0 + +### GOVERN (GV) + +**F11 — Documentação de postura de segurança desatualizada (Informativa)** +*Categoria:* GV.OC (Contexto organizacional) · *SP 800-53:* PL-2 + +`README.md`, seção "Segurança e escopo", ainda afirma que "as garantias de +autenticação, autorização por microárea e entrega MQTT com ACK permanecem +pendentes de integração real". Isso não reflete mais o código: `auth_endpoint.dart` +emite JWT HMAC verificado em cada chamada, `orm_alert_store.dart` reforça o +filtro de microárea no predicado SQL (`t.microAreaId.equals(microAreaUuid)`, +linha 109), e `mqtt_alert_dispatcher.dart` + `alert_outbox_dispatcher.dart` +implementam entrega com ACK e retry. + +Documentação desatualizada nos dois sentidos é um risco: subestimar a +maturidade atrasa a priorização correta (revisores podem redescobrir o que já +existe); superestimar levaria a assumir garantias que não existem — o que não +é o caso aqui, mas vale manter o README como fonte de verdade viva. + +*Recomendação:* atualizar a seção para descrever com precisão o que está +implementado (dev-login JWT + autorização por microárea reforçada no banco) +versus o que continua pendente (autenticação institucional real, MFA, ACL de +microárea no broker gerenciado — ver F1, F2, F5). + +**Governança geral (sem achado numerado)**: não há política de segurança +formal além de `.github/SECURITY.md` (que é boa, mas é uma política de +*divulgação de vulnerabilidades*, não de governança de risco). Para o escopo +de um TCC/piloto, isso é proporcional; não recomendamos um programa de GRC +completo, apenas registrar como item de "aceite de risco" formal antes de +qualquer uso com dados reais, dado que `README.md` e `.github/SECURITY.md` já +proíbem dados reais no piloto. + +### IDENTIFY (ID) + +**F10 — Ausência de scanning automatizado de dependências (Média)** +*Categoria:* ID.RA-01 (vulnerabilidades identificadas e registradas) · *SP 800-53:* RA-5, SA-11 + +`.github/workflows/ci.yml` roda `dart analyze` e os testes, mas não há +`dependabot.yml`, nem passo de `dart pub outdated`/OSV-Scanner/`govulncheck` +equivalente para o ecossistema Dart, nem verificação de dependências dos apps +Flutter (`apps/acs`, `apps/patient`, `apps/admin`) e dos módulos Node +(`video/remotion`). `backend/sinalacs_server/pubspec.lock` fixa `serverpod: +3.4.13`, `mqtt_client: ^10.0.0`, `crypto: ^3.0.3`, `uuid: ^4.5.0` — não há como +avaliar CVEs conhecidos sem acesso à base do pub.dev/OSV no momento desta +análise; o ponto não é que exista uma vulnerabilidade confirmada, é a +ausência de qualquer processo contínuo para detectá-la. + +*Recomendação:* habilitar Dependabot (ou Renovate) para `pubspec.yaml` dos +quatro pacotes Dart e para `video/remotion/package.json`; adicionar um job de +CI que rode `dart pub outdated --mode=null-safety` (ou equivalente) e falhe em +vulnerabilidades conhecidas de severidade alta/crítica. + +### PROTECT (PR) + +**F1 — Dev-login como único mecanismo de autenticação (Alta)** +*Categoria:* PR.AA-01/03 (identidades e credenciais geridas) · *SP 800-53:* IA-2, IA-5 + +`auth_endpoint.dart` expõe `developmentLogin(role)`, que emite um token válido +sem senha real para dois UUIDs fixos de seed. É corretamente gateado por +`ENABLE_DEV_LOGIN` (retorna `EndpointDisabledException` como 404, não 403, +para não revelar a rota — boa prática de "fail closed sem vazar existência"). +Mas, conforme `backend/DEPLOY.md` ("Limitações conhecidas deste piloto"), esse +é **o único mecanismo de autenticação existente**: "qualquer pessoa com a URL +pode se autenticar como paciente ou ACS". Isso já é conhecido e documentado +pelo projeto — o achado aqui é formalizar a severidade e um caminho de saída. + +*Recomendação:* definir e priorizar a integração com identidade institucional +real (ex.: e-SUS APS/CNS para profissionais de saúde, ou um IdP simples com +OAuth2/OIDC para o escopo do TCC) antes de qualquer uso fora do ambiente de +demonstração controlada. Enquanto isso persistir, manter `ENABLE_DEV_LOGIN` +desligado por padrão em qualquer ambiente exposto publicamente (verificar +`backend/DEPLOY.md` para confirmar que a variável não está setada como +`true` no Render). + +**F2 — ACL do broker MQTT no piloto não segrega por microárea (Alta)** +*Categoria:* PR.AA-05 / PR.DS-02 (confidencialidade em trânsito, menor +privilégio) · *SP 800-53:* AC-3, AC-6, SC-8 + +Comparando `infra/docker/mosquitto/aclfile` (ambiente local) com +`backend/DEPLOY.md`: localmente, o Mosquitto aplica ACL por tópico — o usuário +`acs-area-12` só pode ler +`sinalacs/v1/microareas/00000000-.../alerts` da própria microárea. Já no +caminho de deploy documentado (`backend/DEPLOY.md`, seção "Limitações +conhecidas deste piloto"), o broker gerenciado free-tier (HiveMQ Cloud) usa +"usuário/senha único" — sem ACL dinâmica por microárea. Isso é uma +**regressão de confidencialidade entre desenvolvimento e o piloto real**: +qualquer cliente com a credencial única do broker pode assinar +`sinalacs/v1/#` e ver `patientId`, `microAreaId` e `locationHash` de alertas +de **todas** as microáreas, não só a própria — mesmo que o backend e o app do +ACS façam a coisa certa. A invariante de territorialização (INV-01), reforçada +corretamente no banco (`orm_alert_store.dart:106-112`), não se estende ao +transporte MQTT em produção. + +*Recomendação:* no curto prazo, documentar explicitamente esse risco residual +em `backend/DEPLOY.md` (hoje ele lista a limitação técnica, mas não o impacto +de confidencialidade que decorre dela). No médio prazo, avaliar um broker +gerenciado com ACL por tópico no tier gratuito (ex.: EMQX Cloud, já citado como +alternativa na mesma tabela do `DEPLOY.md`) ou aplicar criptografia de +payload por microárea antes de publicar, para que a credencial única do +broker não seja suficiente para ler o conteúdo. + +**F3 — TLS do MQTT não é obrigatório por ambiente (Média)** +*Categoria:* PR.DS-02 (dados em trânsito) · *SP 800-53:* SC-8, SC-13 + +`AppConfig.fromMap` (`app_config.dart:84`) lê `mqttUseTls` de +`MQTT_USE_TLS == 'true'`, com default `false`, e não há validação equivalente +à de `JWT_SECRET`/`AUDIT_CHAIN_SECRET` que recuse subir em `APP_ENV=production` +sem essa flag ligada. Ou seja: o padrão de "falhar no boot em vez de na +primeira requisição" — que o próprio time já aplicou corretamente aos +segredos (`_resolveSecret`, `app_config.dart:101-126`) — não foi estendido ao +transporte MQTT. Um operador que esqueça `MQTT_USE_TLS=true` em produção não +recebe erro nenhum; o tráfego (incluindo `locationHash` e IDs de pacientes) +simplesmente vai em texto claro. + +*Recomendação:* aplicar a mesma política de fail-closed: `AppConfig` deve +recusar subir com `appEnv != 'development'` e `mqttUseTls == false`, no mesmo +padrão de `_resolveSecret`. + +**F4 — Autorização por endpoint é manual, sem enforcement central (Média/Alta)** +*Categoria:* PR.AA-05 (least privilege / segurança por padrão) · *SP 800-53:* AC-3, AC-6 + +Todos os endpoints (`alerts_endpoint.dart`, `patients_endpoint.dart`, +`visits_endpoint.dart`, `triage_endpoint.dart`, `health_endpoint.dart`) +declaram `requireLogin => false` — abrindo mão do mecanismo de sessão nativo +do Serverpod — e cada método chama manualmente +`AlertRuntime.instance.auth.verifyToken(accessToken)`. Isso funciona hoje +porque todo endpoint que toca dado sensível lembra de chamar `_authenticate`. +Mas é um padrão "seguro por convenção", não "seguro por padrão": nada no +framework impede que um endpoint futuro que manipule dados de saúde seja +adicionado sem essa chamada — ele simplesmente ficaria aberto, e nenhum teste +de tipo pegaria isso automaticamente (só revisão humana ou um teste dedicado). +`triage_endpoint.dart` já é hoje um exemplo de endpoint sem autenticação — +aparentemente intencional (a classificação é determinística e sem dado do +paciente), mas isso reforça que a decisão de "quem precisa de token" está +espalhada, não centralizada. + +*Recomendação:* extrair a verificação de token para um método de classe-base +ou um `Endpoint` intermediário (`AuthenticatedEndpoint extends Endpoint`) que +todo endpoint sensível estenda, de forma que esquecer a chamada vire erro de +compilação/design óbvio, não uma omissão silenciosa. Adicionar um teste que +enumere os endpoints e falhe se um novo endpoint com acesso a +`Session.db`/dados de paciente não herdar dessa base. + +**F5 — Ausência de MFA para ACS (Média)** +*Categoria:* PR.AA-01 · *SP 800-53:* IA-2(1) + +`spec/lgpd_design.md` (LGPD-RF11) já define como critério de aceite "MFA para +ACS", mas a única autenticação existente (F1) não implementa nenhum segundo +fator. Enquanto o dev-login for o mecanismo de autenticação, MFA não é +aplicável tecnicamente; o achado é para não perder o requisito de vista +quando a autenticação institucional (F1) for desenhada. + +*Recomendação:* incluir MFA no desenho da autenticação institucional futura +desde já, em vez de tratá-lo como incremento posterior. + +**F6 — Sem rate limiting no `developmentLogin` (Média)** +*Categoria:* PR.AA-01 · *SP 800-53:* AC-7, SC-5 + +`AuthEndpoint.developmentLogin` não tem limite de tentativas nem +throttling — hoje isso importa pouco porque não há senha para forçar (o +`role` só aceita `patient`/`acs`), mas o padrão importa: quando a +autenticação real (F1) substituir isso, o mesmo endpoint (ou seu sucessor) +precisa nascer com rate limiting, para não repetir a lacuna. + +*Recomendação:* tratar como pré-requisito de design da autenticação +institucional, não como item avulso. + +**F7 — Gestão de segredos correta em código, sem cofre/rotação (Baixa/Média)** +*Categoria:* PR.DS-01 · *SP 800-53:* SC-12, SC-28 + +Ponto positivo a registrar: `AppConfig._resolveSecret` +(`app_config.dart:101-126`) já faz o que a maioria dos projetos deste porte +não faz — recusa subir em `staging`/`production` sem `JWT_SECRET`/ +`AUDIT_CHAIN_SECRET` próprios, e recusa mesmo que alguém copie o valor de +desenvolvimento para outro ambiente por engano. O `.gitignore` protege +`config/passwords.yaml`, e o CI corrigiu recentemente +(`ci.yml`, comentário nas primeiras linhas do job) uma senha de teste que +estava compartilhada com o arquivo local gitignorado. O que falta é só +maturidade operacional: os segredos hoje vivem como variáveis de ambiente +simples no Render, sem cofre dedicado (Vault, AWS/GCP Secret Manager) nem +rotação programada. + +*Recomendação:* aceitável para o estágio de piloto; registrar como item de +hardening antes de produção real, não como bloqueador agora. + +### DETECT (DE) + +**F8 — Sem correlação/alerta de eventos de segurança (Média)** +*Categoria:* DE.CM-01/03 (monitoramento contínuo) · *SP 800-53:* AU-6, AU-12, SI-4 + +A issue original afirmava que "o backend só usa `print`" — isso está +desatualizado: a migração para Serverpod trouxe `sessionLogs` estruturados +(`config/*.yaml`, `consoleLogFormat: json` em produção) e um serviço de +auditoria dedicado, `AuditTrail` (`audit_trail.dart`), que grava eventos +`granted`/`denied_territory` de acesso a dado sensível numa trilha +append-only com cadeia de hash (`auditChainSecret`) — desenhada +especificamente para atender "logs de acesso com quem, quando e quais dados" +(LGPD-RF11) e o alerta de tentativa de acesso fora da microárea. Isso é uma +base sólida que a issue não previa. + +O que continua faltando é a camada seguinte: não há nada que **leia** essa +trilha e gere alerta ativo (ex.: N tentativas de `denied_territory` do mesmo +usuário em M minutos), nem centralização fora do host do Render (que +hiberna e não retém logs entre reinícios de forma confiável, conforme +`backend/DEPLOY.md`). Falhas de auditoria também só vão para +`stderr.writeln` (`audit_trail.dart:58`), sem alerta. + +*Recomendação:* para o escopo do piloto, o mínimo viável é exportar +`sessionLogs` e a tabela `audit_logs` para um destino persistente fora do +Render (mesmo que seja um job periódico simples), e alertar manualmente sobre +`denied_territory` como parte da rotina do responsável técnico enquanto não +houver orçamento para uma ferramenta de SIEM. + +### RESPOND (RS) + +**F9 — Sem plano de resposta a incidentes / SLA de notificação (Alta para conformidade LGPD)** +*Categoria:* RS.MA-01, RS.CO-02 · *SP 800-53:* IR-1, IR-4, IR-6 + +`.github/SECURITY.md` cobre bem a **divulgação coordenada de +vulnerabilidades** (relato privado, prazo de confirmação em 5 dias úteis, +divulgação em até 90 dias) — mas o próprio documento é explícito sobre seu +limite: "não substitui... resposta a incidentes. Um evento real que envolva +dados pessoais ou de saúde pode exigir medidas adicionais". `spec/lgpd_design.md` +(LGPD-RF12) já formaliza a obrigação legal: notificação à ANPD e aos +titulares em até 72h (legal) / 48h (recomendado), com "capacidade de +identificar titulares afetados em até 24 horas" — nenhum desses processos +está documentado ou testado. + +*Recomendação:* redigir um runbook curto de resposta a incidentes +(quem aciona, como isolar o `ENABLE_DEV_LOGIN`/rotacionar segredos em +minutos, como consultar `audit_logs` para escopo do incidente, template de +comunicação à ANPD/titulares) — não precisa ser extenso dado o estágio do +projeto, mas precisa existir antes de qualquer piloto com usuários reais, +mesmo que sintéticos por enquanto. + +### RECOVER (RC) + +**F12 — Sem plano de recuperação/backup testado (Baixa)** +*Categoria:* RC.RP-01 · *SP 800-53:* CP-9, CP-10 + +`backend/DEPLOY.md` documenta bem as limitações de disponibilidade do piloto +free-tier (hibernação do Render, autosuspend do Neon), mas não há menção a +backup do Postgres (Neon free tier) nem a um teste de restauração. Para um +piloto sem dados reais, a severidade é baixa; registrar como item a resolver +antes de qualquer dado real trafegar pelo sistema. + +*Recomendação:* documentar a política de retenção/backup do Neon (mesmo que +seja "o free tier não garante backup, logo nenhum dado real deve trafegar +aqui" — o que já é consistente com o restante da política do projeto). + +## 4. Priorização recomendada + +1. **Antes de qualquer dado real (bloqueadores)**: F1 (autenticação + institucional), F2 (ACL do broker MQTT no piloto), F9 (runbook de + incidentes com SLA de notificação). +2. **Curto prazo, baixo custo de implementação**: F3 (fail-closed de TLS no + MQTT), F4 (endpoint base com autenticação obrigatória), F11 (atualizar + README). +3. **Médio prazo**: F5 (MFA), F6 (rate limiting), F10 (scanning de + dependências no CI). +4. **Hardening contínuo, sem bloquear o piloto**: F7 (cofre de segredos), F8 + (alerta ativo sobre a trilha de auditoria), F12 (backup/restore). + +## 5. Referências + +- [NIST Cybersecurity Framework 2.0](https://www.nist.gov/cyberframework) +- [NIST SP 800-53 Rev. 5](https://csrc.nist.gov/pubs/sp/800/53/r5/upd1/final) +- [`CLAUDE.md`](../CLAUDE.md) — invariantes de negócio/segurança (INV-01, + INV-02, INV-03) +- [`spec/lgpd_design.md`](lgpd_design.md) — LGPD-RF09, RF11, RF12, RF13 +- [`spec/PRD_system.md`](PRD_system.md) — seção 6.2 (M3.2 observabilidade, + M3.4 LGPD Compliance) +- [`backend/DEPLOY.md`](../backend/DEPLOY.md) — limitações conhecidas do + piloto free-tier +- [`.github/SECURITY.md`](../.github/SECURITY.md) — política de divulgação + de vulnerabilidades diff --git a/spec/test_plan.md b/spec/test_plan.md index bbe88cf..fd65a0e 100644 --- a/spec/test_plan.md +++ b/spec/test_plan.md @@ -15,7 +15,7 @@ Para otimizar o *Lead Time* e erradicar testes *flaky*, a pirâmide de testes tr * **Testes E2E (10%):** Focados apenas nos Caminhos Críticos (ex: Disparo do Botão de Alerta e Cache de Territorialização). -* **Contratos Orientados a Eventos:** A ausência de um ORM/codegen no backend (decisão original de usar Serverpod não foi implementada) significa que não há hoje quebra de compilação automática em falhas de contrato REST — isso é um risco a mitigar, não uma garantia existente. O risco real está na mensageria assíncrona. Implementaremos validação de contratos **AsyncAPI** para os *payloads* do *broker* Mosquitto, garantindo que o publicador (Paciente) e o consumidor (ACS) falem a mesma linguagem sem corromper o *buffer* de mensagens. +* **Contratos Orientados a Eventos:** Os contratos **síncronos** já têm garantia de compilação: o backend é um workspace Serverpod com ORM e codegen, e o `serverpod generate` produz o cliente Dart tipado em `sinalacs_client` a partir dos modelos `.spy.yaml` — uma quebra de contrato nos endpoints RPC falha na compilação. Essa garantia **não** se estende à mensageria assíncrona, onde está o risco real: os *payloads* do *broker* Mosquitto não passam por codegen. Implementaremos validação de contratos **AsyncAPI** para esses *payloads*, garantindo que o publicador (Paciente) e o consumidor (ACS) falem a mesma linguagem sem corromper o *buffer* de mensagens. diff --git a/spec/ux_accessibility_assessment.md b/spec/ux_accessibility_assessment.md new file mode 100644 index 0000000..ebd1c79 --- /dev/null +++ b/spec/ux_accessibility_assessment.md @@ -0,0 +1,235 @@ +# Relatório de Avaliação de UI/UX e Acessibilidade + +**Aplicativos Avaliados:** `apps/patient`, `apps/acs` e `apps/admin` +**Referência Baseline:** PRD de Sistemas (Seção 4.3 — WCAG 2.1 Nível AA), `spec/ui_design.md` +**Metodologia e Ferramentas:** Matriz de contraste determinística (`apps/*/test/contrast_tokens_test.dart`, +implementando a fórmula de luminância relativa WCAG 1.4.3 contra a superfície REAL de +renderização de cada token — ver §2.1), matchers nativos do Flutter no CI +(`meetsGuideline(textContrastGuideline/androidTapTargetGuideline/labeledTapTargetGuideline)` +em `login_flow_test.dart` e `patient_app_mvp_test.dart`), Auditoria Estática de Código (Dart +AST/grep), Validação em dispositivo real (`apps/acs/integration_test/`, incluindo o teste que lê o +arquivo do banco criptografado) e Verificação Prática no emulador Android (`emulator-5554`) contra +o backend e o broker reais, cobrindo os dois apps em conjunto (paciente envia alerta → ACS recebe +pelo MQTT/TLS real). + +> **Nota de revisão:** a versão anterior deste relatório mediu contraste manualmente no WebAIM +> Contrast Checker contra o fundo do `Scaffold`, mas texto de risco e de status é renderizado +> dentro de `Card`/`AppBar`, com uma cor de superfície diferente. Isso produziu um falso positivo +> (§3, achado A) e deixou passar duas falhas piores (§3, achados B e C). Esta revisão substitui a +> medição manual por um teste determinístico que roda no CI — ver `apps/acs/test/contrast_tokens_test.dart` +> e `apps/patient/test/contrast_tokens_test.dart`. + +--- + +## 1. Comparativo com a Baseline WCAG 2.1 AA (Seção 4.3 do PRD) + +| Critério WCAG 2.1 | Descrição do Critério | Requisito do PRD / Issue | Status | Resumo do Diagnóstico | +| :--- | :--- | :--- | :---: | :--- | +| **1.4.1 Color Use** | A cor não deve ser o único indicador visual de estado/risco | Duplo canal (Texto/Ícone + Cor) em sinais clínicos de risco | **Conforme** | Rótulos explícitos `'Risco: Vermelho'`, `'Risco: Amarelo'`, `'Risco: Verde'` acompanhados de ícone, nos dois apps. | +| **1.4.3 Contrast (Minimum)** | Razão de contraste min. de 4.5:1 (texto normal) e 3:1 (texto grande/UI) | ≥ 4.5:1 para texto sobre fundo escuro nos temas | **Conforme (corrigido)** | Cinco pares de token/superfície falhavam quando medidos corretamente (§2.1); corrigidos separando token de PREENCHIMENTO de token de TEXTO (`redOnSurface`/`accentOnSurface` no ACS, `dangerOnSurface`/`accentOnSurface` no paciente, `redOnSurface`/`accentOnSurface` no admin). Guardado por teste determinístico. | +| **2.4.7 Focus Visible** | Indicador claro de foco visual ao navegar por campos interativos | Foco visível em todos os elementos selecionáveis | **Conforme** | Indicador de foco nativo do Android acompanha todos os alvos tocáveis, sem truncamento. | +| **2.5.5 Target Size** | Alvo de toque adequado para interatividade | ≥ 48x48 dp (padrão), ≥ 60x60 dp (botão de emergência/pânico) | **Conforme (corrigido)** | Botão de pânico do paciente: `208x208 dp`. "Ligar para o SAMU (192)" no ACS não tinha `minimumSize` (default M3 de 40dp de altura visual) — corrigido para `64x60 dp`. Quatro outros botões de ação primária no ACS também não tinham `minimumSize` explícito; padronizados em `48x52 dp`. | +| **4.1.2 Name, Role, Value** | Árvore semântica exposta para leitores de tela nativos | Rótulos e papeis em 100% dos fluxos críticos | **Conforme** | Árvore semântica nativa do Flutter expõe abas (**Área, Fila, Mapa, Visita, Mais**) e formulários com clareza; o cartão de alerta da fila passou a ser lido como uma frase única (§3, achado antigo de prioridade Baixa). | +| **4.1.3 Status Messages** | Uma mudança de status deve ser anunciada por tecnologia assistiva sem exigir foco | (ausente da avaliação anterior) | **Conforme (corrigido)** | Critério não coberto pelo relatório original. Sete pontos de status dinâmico (erro de login nos dois apps, confirmação de alerta de emergência, banners de falha de broker/armazenamento, erro do diretório de pacientes, contador de visitas recusadas) não eram anunciados; corrigidos com `Semantics(liveRegion: true)`. | + +--- + +## 2. Evidências Técnicas e Achados por Critério + +### 2.1 Contraste e Uso de Cor (WCAG 1.4.3 e 1.4.1) + +Cada app declara cores de PREENCHIMENTO (fundo de botão, badge) que também eram reaproveitadas +como cor de TEXTO/ícone sobre `Card`. Um preenchimento e um texto têm requisitos diferentes — um +botão vermelho com texto branco por cima só precisa de 3:1 (texto grande/UI), mas o mesmo +vermelho usado como cor de um `Text` precisa de 4.5:1. A tabela abaixo mede cada par contra a +superfície onde ele é **de fato** renderizado, com composição de alfa quando aplicável +(`Colors.white54` etc.): + +#### Tabela de Razão de Contraste Renderizado + +| App | Token | Papel | Superfície real | Razão (WCAG) | Exigência | Status | +| :--- | :--- | :--- | :--- | :---: | :---: | :---: | +| Ambos | Branco `#FFFFFF` | texto | Scaffold `#030712` | 20.13:1 | 4.5:1 | Conforme | +| Ambos | Branco `#FFFFFF` | texto | Card (`#1F2937`/`#1E293B`) | 14.68:1 / 14.63:1 | 4.5:1 | Conforme | +| ACS | `AcsColors.yellow #F59E0B` | texto | Card `#1F2937` | 6.83:1 | 4.5:1 | Conforme | +| ACS | `AcsColors.green #10B981` | texto | Card `#1F2937` | 5.79:1 | 4.5:1 | Conforme | +| ACS | `AcsColors.red #DC2626` | **texto** | Card `#1F2937` | **3.04:1** | 4.5:1 | **Falha** (achado B) | +| ACS | `AcsColors.accent #2563EB` | **texto** | Card `#1F2937` | **2.84:1** | 4.5:1 | **Falha** (achado C, não detectado antes) | +| ACS | branco | texto do botão | fill `AcsColors.red` | 4.83:1 | 3:1 (UI) | Conforme — fill não muda | +| Admin | `AdminColors.yellow #F59E0B` | texto | Card `#1F2937` | 6.83:1 | 4.5:1 | Conforme | +| Admin | `AdminColors.green #10B981` | texto | Card `#1F2937` | 5.79:1 | 4.5:1 | Conforme | +| Admin | `AdminColors.red #DC2626` | **texto** | Card `#1F2937` | **3.04:1** | 4.5:1 | **Falha** (mesmo achado do ACS — `red` é idêntico nos dois apps) | +| Admin | `AdminColors.accent #4F46E5` | **texto** | Card `#1F2937` | **2.33:1** | 4.5:1 | **Falha** | +| Admin | `AdminColors.accent #4F46E5` | **ícone** | Card `#1F2937` | **2.33:1** | 3:1 (1.4.11) | **Falha** (ícone do banner "Ambiente de desenvolvimento") | +| Admin | `AdminColors.accent #4F46E5` | **texto** | AppBar `#111827` | **2.82:1** | 4.5:1 | **Falha** (eyebrow do cabeçalho) | +| Admin | branco | texto do botão | fill `AdminColors.red` | 4.83:1 | 3:1 (UI) | Conforme — fill não muda | +| Paciente | `Colors.white54` | texto | Card `#1E293B` | **5.36:1** | 4.5:1 | **Conforme** — achado A do relatório anterior era falso positivo (media 3.2:1 contra o Scaffold) | +| Paciente | amarelo `#E0A800` | texto | Card `#1E293B` | 6.81:1 | 4.5:1 | Conforme | +| Paciente | `PatientColors.accent #0D9488` | **texto** | Card `#1E293B` | **3.91:1** | 4.5:1 | **Falha** (achado D, relatado antes como conforme por medir no Scaffold) | +| Paciente | `PatientColors.danger #DC2626` | **texto** | Card `#1E293B` | **3.03:1** | 4.5:1 | **Falha** | +| Paciente | `PatientColors.danger #DC2626` | **texto** | Scaffold `#030712` | **4.17:1** | 4.5:1 | **Falha** — este é o par que o relatório original mediu (4.16:1) e classificou "Média"; a correção sugerida (`#EF4444`) foi avaliada e descartada (linha abaixo) | +| Paciente | branco | texto do botão | fill `PatientColors.danger` | 4.83:1 | 3:1 (UI) | Conforme — fill não muda | +| — | `#EF4444` (correção sugerida pelo relatório anterior) | texto | Card `#1F2937` | **3.90:1** | 4.5:1 | **Ainda falha** — não adotada | + +Reprodução: `flutter test test/contrast_tokens_test.dart` em cada um dos três apps. + +#### Correção aplicada: separar token de preenchimento de token de texto + +Clarear o token único (a sugestão original, `#EF4444`) resolvia a leitura sobre o `Scaffold` mas +continuava falhando sobre `Card` (3.90:1) — não bastava. A correção adotada foi acrescentar uma +variante de TEXTO a cada token de preenchimento que falhava como texto, mantendo o preenchimento +original intacto (ele já cumpre 3:1 com texto branco por cima): + +| App | Novo token | Valor | Sobre `surfaceRaised` | Substitui, como texto | +| :--- | :--- | :--- | :---: | :--- | +| ACS | `AcsColors.redOnSurface` | `#F87171` | 5.31:1 | `AcsColors.red` | +| ACS | `AcsColors.accentOnSurface` | `#60A5FA` | 5.77:1 | `AcsColors.accent` | +| Admin | `AdminColors.redOnSurface` | `#F87171` | 5.31:1 | `AdminColors.red` | +| Admin | `AdminColors.accentOnSurface` | `#818CF8` | 4.92:1 | `AdminColors.accent` | +| Paciente | `PatientColors.dangerOnSurface` | `#F87171` | 5.29:1 | `PatientColors.danger` | +| Paciente | `PatientColors.accentOnSurface` | `#2DD4BF` | 7.86:1 | `PatientColors.accent` | + +O admin não reaproveita o `#60A5FA` do ACS para `accentOnSurface`: o accent do backoffice é +indigo `#4F46E5`, não o azul `#2563EB` do ACS — copiar o valor literal passaria no contraste +(4.5:1+) mas trocaria o matiz, deixando indigo e azul lado a lado no mesmo banner, onde +`AdminColors.accent` continua como borda. `#818CF8` é o passo -400 da mesma cor do fill -600, +a mesma relação usada pelos outros dois apps. + +`AcsColors.red`/`AcsColors.accent`, `PatientColors.danger`/`PatientColors.accent` e +`AdminColors.red`/`AdminColors.accent` continuam sendo a cor de PREENCHIMENTO do botão de +pânico, do botão "Ligar para o SAMU", do botão "Confirmar recebimento" e da faixa lateral de +risco do admin — nenhum desses mudou de cor. Um helper único (`acsOnSurface()` no ACS, +`adminOnSurface()` no admin) converte a cor de preenchimento na variante de texto no ponto em +que um `switch` de risco alimenta tanto um `backgroundColor`/borda quanto um `TextStyle`, para +nunca haver dois pontos de verdade sobre qual vermelho usar onde. + +#### Validação de Duplo Canal (WCAG 1.4.1) + +`_TriageResult` (paciente) e `_AlertCard`/`_riskLabelPt` (ACS) usam rótulos de texto explícitos +concatenados ao sinal visual: `'Risco: Vermelho'`, `'Risco: Amarelo'`, `'Risco: Verde'`. Um +defeito adjacente foi corrigido nesta revisão: `GeofencingScreen` e `EscalationScreen` exibiam a +string crua vinda do servidor (`"red"`) em vez do rótulo traduzido — único ponto do app que não +passava pelo mapeamento, confirmado e corrigido junto com a auditoria de contraste. + +--- + +### 2.2 Alvos de Toque (WCAG 2.5.5) + +| App | Componente / Fluxo | Dimensão Renderizada | Mínimo Requerido | Status | +| :--- | :--- | :---: | :---: | :---: | +| Paciente | Botão de Emergência/Pânico (`panic_button`) | `208 x 208 dp` | 60 x 60 dp | Conforme (Amplo) | +| Paciente | Botões de Login e Envio de Triagem | `48 x 52 dp` | 48 x 48 dp | Conforme | +| ACS | Ações de Login, Confirmar Recebimento e Sincronizar | `48 x 52 dp` | 48 x 48 dp | Conforme | +| ACS | "Ligar para o SAMU (192)" | ~~`48x52 dp`~~ **sem `minimumSize` → 40dp de altura visual** | 60 x 60 dp | **Corrigido para `64x60 dp`** — a medição anterior ("48x52 dp") estava incorreta; o botão não declarava `minimumSize` e caía no default do Material 3. | +| ACS | "Salvar e enfileirar sincronização", "Descartar recusada(s)", "Encaminhar para UBS Central", "Iniciar rota de visita" (escalonamento) | sem `minimumSize` (40dp) | 48 x 48 dp | **Corrigido para `48x52 dp`**, não detectados no relatório anterior | + +Reprodução: `meetsGuideline(androidTapTargetGuideline)` em ambos os apps (`login_flow_test.dart`, +`patient_app_mvp_test.dart`). + +--- + +### 2.3 Rótulos Semânticos e Navegação por Leitor de Tela (WCAG 4.1.2) + +#### Inspeção Estática de Código +* **Instâncias de `Semantics()`:** Login ACS (`app.dart`), Login Paciente, Botão de Pânico, + banners de infraestrutura, e — nesta revisão — o cartão de alerta da fila. +* **Instâncias de `tooltip:`:** `'Enviar mensagem'`, `'Novo alarme'` no app paciente. + +#### Correção: cartão de alerta lido como frase única + +Achado de prioridade "Baixa" do relatório anterior, agora corrigido: o `_AlertCard` do ACS era +lido pelo TalkBack como quatro nós soltos ("Paciente 3f2a1b8c" / "Risco: Vermelho" / "Recebido +às..." / botão), sem ligação entre as informações. `Semantics(label: ..., excludeSemantics: true)` +envolve o bloco de identificação (paciente, risco, confirmação, horário) numa frase única; os +botões de ação continuam como nós próprios, fora do bloco. Coberto por teste +(`login_flow_test.dart`, "o cartão de alerta é lido como uma frase única pelo leitor de tela") e +confirmado em dispositivo real no emulador. + +#### Teste Prático em Dispositivo (emulador Android + integração real) + +* **Fluxo completo ponta a ponta:** app Paciente disparou um alerta de emergência real contra o + backend rodando em Docker Compose; o app ACS recebeu o alerta pelo broker MQTT/TLS real, + exibiu-o na fila com o novo `redOnSurface`, e o fluxo de escalonamento mostrou o botão do SAMU + no novo tamanho e o risco traduzido (`_riskLabelPt`). +* **14 testes de integração em dispositivo** (`apps/acs/integration_test/`, incluindo + `encrypted_storage_test.dart`, que lê o arquivo do banco e confirma que não é SQLite em texto + plano) passam sobre o código revisado — a única forma de provar criptografia real, e a mesma + suíte que exercita as telas de mapa e escalonamento tocadas por esta revisão. + +--- + +### 2.4 Indicador de Foco Visível (WCAG 2.4.7) + +Sem alteração nesta revisão: o indicador de foco nativo do sistema acompanha os alvos tocáveis +sem truncamento, confirmado durante a navegação manual no emulador. + +--- + +### 2.5 Status Messages (WCAG 4.1.3) — critério novo + +Ausente da avaliação anterior. Um leitor de tela só percebe uma mudança de conteúdo que não move +o foco se o nó semântico correspondente estiver marcado como região viva +(`Semantics(liveRegion: true)`). Sete pontos de status dinâmico foram auditados e não tinham essa +marcação: + +| App | Local | O que muda | +| :--- | :--- | :--- | +| Paciente | `login_error` | Falha de autenticação | +| Paciente | `_state` do alerta de emergência | Confirmação de que o alerta chegou à equipe — o ponto mais crítico do app | +| Paciente | `triage_error` | Falha ao enviar a triagem | +| ACS | `login_error` | Falha de autenticação | +| ACS | `feed_error` / `storage_error` (`_InfraBanner`) | Broker ou armazenamento local caindo | +| ACS | `visit_storage_error` | Persistência falhando durante o registro da visita | +| ACS | `patient_directory_error` | Falha ao carregar pacientes da microárea | +| ACS | `rejected_visits_count` | Servidor recusou uma visita em definitivo | + +Todos corrigidos com `Semantics(liveRegion: true)`. Cobertos por teste +(`SemanticsFlags.isLiveRegion`, em `login_flow_test.dart` e `patient_app_mvp_test.dart`) e +confirmados em dispositivo: o texto "Alerta recebido pela equipe" do fluxo de emergência real +chegou marcado como região viva. + +--- + +## 3. Matriz de Achados — Situação Após Esta Revisão + +| Prioridade original | Critério | Localização | Situação nesta revisão | +| :---: | :---: | :--- | :--- | +| Alta | 1.4.3 | `patient/app.dart` (`Colors.white54`) | **Refutado.** 5.36:1 sobre `Card` — a medição original comparava contra o Scaffold. Nenhuma ação necessária. | +| Média | 1.4.3 | `acs_theme.dart`/`patient_theme.dart` (vermelho como texto) | **Corrigido**, mas o remédio proposto (`#EF4444`) foi descartado por insuficiência (3.90:1 sobre card); adotada a separação fill/texto (§2.1). | +| (não detectado) | 1.4.3 | `AcsColors.accent`/`PatientColors.accent` como texto sobre card | **Corrigido** (achados C e D) — pior que o item "Média" do relatório anterior e não constava nele. | +| Média | 2.5.5 | Botão do SAMU | **Corrigido** para `64x60 dp`; a medição original ("48x52 dp") estava incorreta — o botão não tinha `minimumSize`. | +| Baixa | 4.1.2 | Cartões de alerta do ACS | **Corrigido** — cartão agora é uma frase semântica única. | +| (não avaliado) | 4.1.3 | Sete pontos de status dinâmico | **Critério ausente da baseline anterior, incorporado e corrigido nesta revisão.** | + +--- + +## 4. Planejamento de Testes de Usabilidade Remota (UXtweak) + +Sem alteração — planejamento de teste com usuários reais, independente dos achados técnicos +corrigidos acima. + +### Cenário 1: Disparo de Alerta de Urgência (App Paciente) +* **Objetivo:** Avaliar o tempo de reação e a taxa de sucesso no acionamento do botão de pânico. +* **Métrica:** Tempo até o primeiro clique (*First-Click*) e taxa de conclusão sem erros. +* **Instrução dada ao usuário:** *"Você está se sentindo mal e precisa acionar o atendimento de emergência imediatamente. Qual botão você pressiona?"* + +### Cenário 2: Priorização na Fila de Atendimento (App ACS) +* **Objetivo:** Avaliar a clareza da visualização dos sinais de risco em ambiente com baixo ruído visual. +* **Métrica:** Taxa de cliques corretos no paciente de maior risco da lista. +* **Instrução dada ao usuário:** *"No seu painel de atendimento, identifique o paciente que exige visita prioritária imediata e acesse os detalhes dele."* + +--- + +## 5. Cobertura Automatizada (substitui a auditoria manual) + +| Verificação | Onde roda | O que impede de regredir | +| :--- | :--- | :--- | +| Matriz de contraste WCAG 1.4.3 | `apps/{acs,patient}/test/contrast_tokens_test.dart` | Qualquer token de tema cair abaixo de 4.5:1/3:1 na superfície real | +| `meetsGuideline` (contraste + alvo de toque) | `login_flow_test.dart`, `patient_app_mvp_test.dart` | Regressão de contraste ou alvo de toque nas telas de login | +| `SemanticsFlags.isLiveRegion` | idem | Status dinâmico deixar de ser anunciado | +| Rótulo semântico do cartão de alerta | `login_flow_test.dart` | Cartão voltar a ser lido como nós soltos | +| 14 testes de integração em dispositivo | `apps/acs/integration_test/` | Regressão de fluxo real (criptografia, MQTT/TLS, mapa, escalonamento) | + +Este conjunto roda no CI (`serverpod-backend`, `patient-app`, `acs-app` — ver `.github/workflows/ci.yml` +para os dois primeiros grupos; o de integração em dispositivo é manual via `scripts/qa/e2e.sh --emulator`) +e é a evidência que substitui as capturas de tela do WebAIM desta revisão em diante. diff --git a/spec/ux_ui_test_plan.md b/spec/ux_ui_test_plan.md new file mode 100644 index 0000000..eee363e --- /dev/null +++ b/spec/ux_ui_test_plan.md @@ -0,0 +1,162 @@ +# Plano de Testes de UX/UI — apps/patient e apps/acs + +**Referência normativa:** `spec/ui_design.md` (linguagem visual e comportamento de UX). +**Escopo:** comportamento visual e de interação — responsividade, hierarquia, microinterações, +modo escuro, fricção de fluxo. Não duplica `spec/ux_accessibility_assessment.md` (WCAG 2.1 AA: +contraste, alvo de toque, semântica, status messages) — os dois documentos são complementares. + +`spec/ui_design.md` faz quatro afirmações transversais e três específicas por app. Cada uma vira +uma frente de teste abaixo, já cruzada com o estado atual do código (`apps/acs/lib/app/app.dart`, +`apps/patient/lib/app/app.dart`) para que a atividade comece por uma hipótese concreta, não por +uma exploração às cegas. + +--- + +## 1. Testes Transversais (Ambos os Apps) + +### 1.1 Container responsivo mobile-first, centralizado em telas largas + +> *"container responsivo que simula a tela do celular e adapta-se centralizado em computadores desktop"* + +| | | +|---|---| +| **Método** | `flutter run -d emulator-5554` redimensionando a janela (Android em modo desktop/tablet, ou `flutter run -d chrome` se aplicável) e `flutter test` com `tester.view.physicalSize` variando de 360dp a 1280dp de largura. | +| **Achado a confirmar** | Hoje só a tela de login aplica `ConstrainedBox(maxWidth: ...)` + `Center` — [patient/app.dart:91-92](apps/patient/lib/app/app.dart#L91) (420dp) e [acs/app.dart:146-147](apps/acs/lib/app/app.dart#L146) (600dp). Todas as telas pós-login do ACS passam pelo helper `_page()` ([acs/app.dart:1571](apps/acs/lib/app/app.dart#L1571)), que é só `ListView(Card(...))` sem `ConstrainedBox` nem `Center` — em janela larga, o conteúdo estica borda a borda. O mesmo vale para as telas pós-login do paciente (Triagem, Status, Perfil, Lembretes). | +| **Critério de aceite** | Decisão de produto: ou (a) confirmar que a regra vale só para o login (então corrigir `ui_design.md`, que fala em "as telas" no plural) ou (b) estender `ConstrainedBox`/`Center` para os `_page()` e para as telas pós-login do paciente, replicando a intenção descrita. | +| **Prioridade** | Alta — é a primeira frase do documento de design e hoje só ⅓ das telas a cumprem. | + +### 1.2 Dark Mode nativo (economia de bateria / fadiga visual) + +> *"Dark Mode Nativo focado em economia de bateria e redução de fadiga visual"* + +| | | +|---|---| +| **Método** | Alternar o modo claro/escuro do sistema Android (`adb shell "cmd uimode night yes/no"`) com o app aberto e confirmar visualmente e via `flutter test` que a UI **não muda**. | +| **Achado a confirmar** | Nenhum dos dois `MaterialApp` declara `themeMode`/`darkTheme` ([acs/app.dart:66](apps/acs/lib/app/app.dart#L66), [patient/app.dart:33](apps/patient/lib/app/app.dart#L33)) — só `theme: buildAcsTheme()/buildPatientTheme()`, cada um com `brightness: Brightness.dark` fixo dentro do `ThemeData`. Isso é "dark mode nativo" no sentido de único e permanente, não adaptativo ao SO. | +| **Critério de aceite** | Confirmar que essa é a intenção do produto (provável, dado o contexto clínico/campo) e documentar explicitamente em `ui_design.md` que não há modo claro — hoje o texto pode ser lido como se o app seguisse o tema do sistema, o que não acontece. | +| **Prioridade** | Baixa (comportamento correto, só falta registrar a decisão). | + +### 1.3 Estados de foco suave + +> *"estados de foco suave"* + +Já coberto tecnicamente por WCAG 2.4.7 em `spec/ux_accessibility_assessment.md` §2.4. Adicionar +aqui apenas a checagem **visual** (não semântica) com teclado físico/Bluetooth ligado ao +emulador: confirmar que o anel de foco do Material 3 (herdado, nenhum `focusColor`/`overlayColor` +customizado em `acs_theme.dart`/`patient_theme.dart`) é legível sobre `AcsColors.surfaceRaised` e +`PatientColors.surfaceRaised` — não só presente, mas com contraste suficiente contra o card escuro. +**Prioridade:** Baixa. + +### 1.4 Microinterações de clique (`active:scale`) + +> *"microinterações de clique (active:scale)"* + +| | | +|---|---| +| **Achado a confirmar** | O termo vem literalmente dos protótipos HTML — `transition-transform active:scale-95`/`active:scale-[0.98]` em `spec/ui_acs/acs_1_login.html`, `acs_3_dashboard_priorizacao.html`, `acs_4_mapa_interativo.html` etc. Os widgets Flutter reais (`FilledButton`/`OutlinedButton`) usam o **ripple** M3 nativo, não um scale-down — não há `AnimatedScale`/`GestureDetector` customizado em nenhum dos dois apps. | +| **Método** | Tocar os botões primários dos dois apps no emulador (login, pânico, confirmar recebimento, SAMU, sincronizar) e julgar se o feedback tátil percebido (ripple M3) cumpre a mesma função do `active:scale` do protótipo — resposta imediata e visível ao toque — mesmo sendo um mecanismo diferente. | +| **Critério de aceite** | Isto não é uma divergência a corrigir por padrão — ripple é a linguagem idiomática do Material/Flutter e o protótipo HTML é referência visual, não código-fonte (`CLAUDE.md`: "não são código vivo"). Registrar a equivalência intencional em `ui_design.md` para não ser lido como pendência de implementação. | +| **Prioridade** | Baixa — é uma verificação de intenção, não um teste de regressão. | + +--- + +## 2. Testes Específicos — App Paciente + +### 2.1 Baixíssima fricção no fluxo crítico + +> *"Focado em baixíssima fricção... na urgência"* + +| | | +|---|---| +| **Método** | Medir o número de toques e o tempo decorrido do app aberto até o alerta confirmado: `login (1 toque) → aba Urgência (1 toque) → botão de pânico (1 toque) → confirmar no diálogo (1 toque)`. Repetir cronometrando com `tool/live_check.dart` como baseline de rede e comparando com um cronômetro manual no emulador. | +| **Critério de aceite** | ≤ 4 toques e ≤ 10s do app aberto ao alerta confirmado em rede saudável (hoje bate: 4 toques, medido nesta sessão no emulador-5554). Definir esse número explicitamente em `spec/ui_design.md`/PRD como métrica, já que hoje é qualitativo ("baixíssima fricção") sem limiar. | +| **Prioridade** | Média — vale como guarda de regressão de fluxo, não achado de defeito. | + +### 2.2 Contraste elevado + +Já coberto por `spec/ux_accessibility_assessment.md` (WCAG 1.4.3, matriz determinística em +`apps/patient/test/contrast_tokens_test.dart`). Sem atividade nova aqui — apontar para lá. + +### 2.3 Hierarquia visual: o Botão de Emergência domina a tela + +> *"O 'Botão de Emergência' domina a hierarquia visual"* + +| | | +|---|---| +| **Método** | Captura de tela da aba Urgência no emulador e checagem por área ocupada: o círculo do `panic_button` ([patient/app.dart:327-343](apps/patient/lib/app/app.dart#L327)) mede `208x208dp`, o maior elemento tocável do app — comparar com os demais botões (`48x52dp`) e confirmar que nenhum elemento da tela (texto, ícone, card informativo) compete visualmente em tamanho ou saturação de cor. | +| **Critério de aceite** | O botão deve seguir sendo o elemento de maior área e maior saturação cromática (vermelho puro `PatientColors.danger`, sem tingir) em toda tela onde aparece — hoje conforme, confirmado nesta sessão via captura real no emulador. Vale como teste de regressão visual (golden test ou revisão manual) a cada mudança na `TriageScreen`/`UrgencyScreen`. | +| **Prioridade** | Média — proteger contra um elemento futuro (banner, notificação) que dispute a hierarquia sem intenção. | + +--- + +## 3. Testes Específicos — App ACS + +### 3.1 Cor restrita ao ranqueamento clínico determinístico (nunca decorativa) + +> *"O design restringe completamente as cores de emergência (Vermelho, Amarelo, Verde) ao ranqueamento clínico determinístico"* + +| | | +|---|---| +| **Método** | Auditoria estática: `grep` por `AcsColors.red`/`.yellow`/`.green` em `apps/acs/lib/app/app.dart` e classificar cada ocorrência como (a) ligada a `RiskLevel`/`riskLevel` vindo do servidor ou (b) decorativa/UI genérica. Repetir a cada PR que toque o arquivo. | +| **Achado a confirmar** | Todas as ocorrências atuais de `red`/`yellow`/`green` estão ligadas a `alert.riskLevel` (fila, mapa, escalonamento) — nenhuma decorativa. O único ponto de atenção é `AcsColors.green` no botão "Local alcançado" do fluxo de geofencing ([acs/app.dart:1495](apps/acs/lib/app/app.dart#L1495)) e no aviso "Local alcançado, pode registrar a visita" ([acs/app.dart:840](apps/acs/lib/app/app.dart#L840)) — não é `RiskLevel`, é status de chegada geográfica. Decidir se isso é uma violação da regra (cor clínica usada para outro sinal) ou uma exceção aceitável (verde = "ok/liberado" é convenção universal, não compete com risco na mesma tela). | +| **Critério de aceite** | Zero uso de `red`/`yellow`/`green` fora do mapeamento de `RiskLevel`, ou uma exceção documentada explicitamente em `ui_design.md` para o "verde de status operacional". | +| **Prioridade** | Média — risco de erosão silenciosa da regra a cada nova tela. | + +### 3.2 Fila dinâmica com reordenação determinística + +> *"Fila Dinâmica"* + +| | | +|---|---| +| **Método** | No emulador, com o app ACS aberto na aba Fila, disparar do app Paciente (ou via `tool/_manual_probe_create_alert.dart`) alertas de risco crescente e decrescente em sequência e observar a reordenação em tempo real sem precisar puxar para atualizar. | +| **Critério de aceite** | Já coberto por teste automatizado (`AlertQueue`, `login_flow_test.dart` — "deve ordenar por risco e, no mesmo risco, pelo mais antigo"). Esta atividade é a confirmação **visual** de que a transição de posição no `ListView` é perceptível (não um "pulo" brusco que confunda o ACS em campo) — hoje não há `AnimatedList`/transição de reordenação, é um rebuild direto. Avaliar se vale a pena uma animação de reordenação para reduzir a chance de o ACS perder de vista qual card é o novo alerta de maior risco. | +| **Prioridade** | Média — impacto direto em erro de priorização humana, não só estética. | + +### 3.3 UX do offline-first (não só a mecânica de sincronização) + +> *"Offline-first"* + +Complementa (não duplica) o teste funcional de `offline_visit_queue.dart` já coberto em +`login_flow_test.dart`. Aqui o foco é o que o ACS **vê e entende** em campo, sem sinal: + +| | | +|---|---| +| **Método** | Usar `network_chaos_simulator.dart` (ou desligar o Wi-Fi do emulador) durante o registro de uma visita e observar, sem consultar o código, se fica claro: (1) que a visita foi salva localmente, (2) que não foi enviada ainda, (3) o que fazer para enviá-la depois. | +| **Achado a confirmar** | Os elementos existem (`pending_visits_count`, banner `storage_error`, botão `sync_visits`) mas estão espalhados em pontos diferentes da tela de Visita — avaliar se a leitura em sequência (de cima para baixo) conta essa história na ordem certa para alguém sem contexto técnico. | +| **Critério de aceite** | Um ACS em campo, sem explicação prévia, consegue dizer corretamente "isso foi salvo mas não enviado, preciso sincronizar depois" só olhando a tela — validar como um cenário do UXtweak (`spec/ux_accessibility_assessment.md` §4) em vez de auditoria técnica. | +| **Prioridade** | Alta — é o risco arquitetural nº1 citado em `AGENTS.md`. | + +### 3.4 Container mais largo para acomodar dados de triagem + +> *"O container é um pouco mais largo (max-w-2xl) para acomodar dados de triagem mantendo a estrutura UI de aplicativo"* + +| | | +|---|---| +| **Método** | Mesmo método do item 1.1, mas comparando os dois valores: protótipo HTML usa `max-w-2xl` (672px, ver `spec/ui_acs/*.html`) contra os `600dp` hoje hardcoded só na tela de login do ACS ([acs/app.dart:147](apps/acs/lib/app/app.dart#L147)). | +| **Critério de aceite** | Definir se `600dp` é a tradução intencional de `max-w-2xl` (razoável, mas nunca documentada) e — como no item 1.1 — se esse limite deveria valer também para `VisitRegistrationScreen`, onde "dados de triagem" (seletor de paciente, condições crônicas, observações) de fato vivem, hoje sem nenhum limite de largura. | +| **Prioridade** | Alta — mesma causa raiz do item 1.1, mas este é o caso que `ui_design.md` cita explicitamente por nome (dados de triagem), tornando a lacuna mais visível. | + +--- + +## 4. Matriz de dispositivos/viewports recomendada + +| Classe | Exemplo | Por quê | +|---|---|---| +| Telefone compacto | 360x800dp (`emulator-5554` no perfil padrão) | Baseline mobile-first — já validado nesta sessão. | +| Telefone grande / phablet | 412x915dp | Ponto onde o card de `_page()`/telas pós-login começa a esticar sem limite (item 1.1/3.4). | +| Tablet 7-10" | 800x1280dp, 1280x800dp (landscape) | Cenário onde a ausência de `ConstrainedBox` fora do login fica mais evidente — provável uso real de um ACS com tablet institucional. | +| Desktop (debug/dev) | `flutter run -d linux`/Chrome, janela redimensionável | Só para os itens 1.1/3.4; não é alvo de produção segundo `spec/stack.md`, mas é o ambiente mais rápido para iterar o teste de container. | + +## 5. Ferramentas e ambiente + +- **Emulador real** (`emulator-5554`, já configurado): itens 1.2, 2.1, 2.3, 3.2, 3.3 — qualquer + teste que dependa de percepção visual, tempo ou feedback tátil precisa do dispositivo real, não + de `flutter test`. +- **`flutter test` + `tester.view.physicalSize`**: itens 1.1 e 3.4 — regressão de container é + determinística e não precisa de emulador uma vez definido o critério de aceite. +- **`scripts/qa/e2e.sh --emulator`**: baseline de que nenhuma mudança de UX quebra o fluxo real + contra backend/broker — rodar antes e depois de qualquer alteração motivada por este plano. +- **`network_chaos_simulator.dart`**: item 3.3. +- **UXtweak** (já planejado em `spec/ux_accessibility_assessment.md` §4): estender o Cenário 2 + (priorização na fila) para cobrir também o item 3.3 (entendimento do estado offline), em vez de + abrir uma frente de pesquisa nova. diff --git a/video/rpc_demo/watch_mqtt.sh b/video/rpc_demo/watch_mqtt.sh index 441f769..4ab7f36 100755 --- a/video/rpc_demo/watch_mqtt.sh +++ b/video/rpc_demo/watch_mqtt.sh @@ -5,21 +5,40 @@ # painel esquerdo: ./watch_mqtt.sh (este script, deixe rodando) # painel direito: dart run bin/red_alert_cycle.dart # -# NOTA SOBRE O USUÁRIO: assinamos como `backend`, não como um ACS. O aclfile -# (infra/docker/mosquitto/aclfile) concede leitura ao usuário `acs-area-12` no -# tópico `sinalacs/v1/microareas/area-12/alerts`, mas o dispatcher publica em -# `sinalacs/v1/microareas//alerts`. Os dois não se encontram, -# então um assinante com escopo de ACS não receberia nada hoje. Isto demonstra -# a PUBLICAÇÃO, não o isolamento territorial — não afirme o segundo na locução. +# NOTA SOBRE O USUÁRIO: assina como `backend`, que tem leitura em +# `sinalacs/v1/#`, para poder mostrar o curinga `+` e qualquer microárea em +# quadro. Um assinante com escopo de ACS (`acs-area-12`) hoje TAMBÉM receberia o +# alerta: o aclfile foi corrigido para o UUID de microárea do seed, que é onde o +# dispatcher realmente publica. Ainda assim, o que este painel demonstra é a +# PUBLICAÇÃO — para falar de isolamento territorial em locução, mostre o ACS +# sendo negado em outra microárea. +# +# A senha vem do .env (gerada por scripts/dev/bootstrap_env.sh), não de um +# default embutido: as senhas de desenvolvimento passaram a ser por máquina. set -euo pipefail cd "$(dirname "${BASH_SOURCE[0]}")/../.." +if [[ -z "${MQTT_BACKEND_PASSWORD:-}" ]]; then + if [[ -f .env ]]; then + set -a + # shellcheck disable=SC1091 + source .env + set +a + fi +fi + +if [[ -z "${MQTT_BACKEND_PASSWORD:-}" ]]; then + echo 'erro: MQTT_BACKEND_PASSWORD não definido e .env não encontrado.' >&2 + echo 'Rode ./scripts/dev/bootstrap_env.sh na raiz do repositório.' >&2 + exit 1 +fi + echo "escutando sinalacs/v1/microareas/+/alerts (Ctrl-C para sair)" echo exec docker compose exec -T mosquitto mosquitto_sub \ -h mosquitto -p 8883 \ --cafile /mosquitto/certs/ca.crt \ - -u backend -P "${MQTT_BACKEND_PASSWORD:-development-backend-password}" \ + -u backend -P "$MQTT_BACKEND_PASSWORD" \ -t 'sinalacs/v1/microareas/+/alerts' -v